Skip to main content

quillmark_core/
session.rs

1use crate::quill::QuillConfig;
2use crate::{
3    ContentHit, Diagnostic, Document, RenderError, RenderOptions, RenderResult, RenderedRegion,
4    Severity,
5};
6pub use quillmark_content::{ApplyError, Assoc, ChangeBundle, Delta, IslandOp, LineOp, MarkOp, Op};
7
8/// What a committed [`LiveSession::update`] changed.
9#[derive(Debug, Clone, PartialEq, Eq)]
10#[non_exhaustive]
11pub struct ChangeSet {
12    /// Page count after the edit.
13    pub page_count: usize,
14    /// Pages whose rendered content differs from the previous compile,
15    /// including pages the edit added. Pages the edit removed are implied by
16    /// `page_count`. A preview repaints `dirty ∩ visible` and nothing else.
17    pub dirty_pages: Vec<usize>,
18}
19
20impl ChangeSet {
21    /// Both facts an update always reports.
22    pub fn new(page_count: usize, dirty_pages: Vec<usize>) -> Self {
23        Self {
24            page_count,
25            dirty_pages,
26        }
27    }
28}
29
30/// Backend-specific session implementation.
31///
32/// Implementors must be `'static`, `Send`, and `Sync`. The `'static` bound
33/// prevents borrowing source data: own anything you need to keep alive for
34/// the session's lifetime.
35#[doc(hidden)]
36pub trait SessionHandle: Send + Sync + 'static {
37    fn render(&self, opts: &RenderOptions) -> Result<RenderResult, RenderError>;
38    fn page_count(&self) -> usize;
39
40    /// Recompile the session against new document data.
41    ///
42    /// Transactional: on `Err` the previous compile stays live: every read
43    /// (`render`, `render_rgba`, `page_size_pt`, `regions`) keeps serving it.
44    /// A backend with a persistent compilation environment recompiles
45    /// incrementally; one whose compile is cheap recompiles fully. Either way
46    /// the returned [`ChangeSet`] reports the pages the edit visibly changed.
47    /// Default: update is unsupported.
48    fn update(&mut self, _json_data: &serde_json::Value) -> Result<ChangeSet, RenderError> {
49        Err(RenderError::from_diag(
50            Diagnostic::new(
51                Severity::Error,
52                "this backend's session does not support update".to_string(),
53            )
54            .with_code("backend::update_unsupported".to_string()),
55        ))
56    }
57
58    /// Page dimensions in points (1 pt = 1/72"), or `None` if `page` is out of
59    /// range. The canvas-preview seam: a backend that can rasterize pages
60    /// overrides this and [`render_rgba`](Self::render_rgba). Default `None`
61    /// marks the session as having no canvas painter: the painter dispatches
62    /// generically through these two methods rather than downcasting to a
63    /// backend-specific session type.
64    fn page_size_pt(&self, _page: usize) -> Option<(f32, f32)> {
65        None
66    }
67
68    /// Render `page` to a non-premultiplied RGBA8 buffer at `scale`× the natural
69    /// 72-ppi size, returning `(width_px, height_px, rgba)` (row-major, `w*h*4`
70    /// bytes), or `None` if `page` is out of range or the backend has no canvas
71    /// painter. The other half of the seam paired with
72    /// [`page_size_pt`](Self::page_size_pt).
73    ///
74    /// # Per-backend contract
75    ///
76    /// A backend that returns `Some` here guarantees a **complete** raster of
77    /// the page: every piece of page content is already visible in the returned
78    /// pixels. The caller paints them straight to a canvas with **no
79    /// compositing** of its own. Backends satisfy this differently:
80    ///
81    /// - **Typst** rasterizes its laid-out page natively.
82    /// - **pdfform** pre-flattens the bound field values into the page content
83    ///   streams at session-open, then rasterizes that flat PDF, so field
84    ///   values appear in the raster without the caller drawing them.
85    ///
86    /// The [`regions`](Self::regions) accessor carries per-field geometry keyed
87    /// on the quill schema field path, for *overlay* / cross-navigation UIs
88    /// regardless; it is never required to make the raster complete.
89    ///
90    /// A backend with no painter overrides neither this nor
91    /// [`page_size_pt`](Self::page_size_pt); the defaults mark the session as
92    /// non-canvas, which is exactly what [`LiveSession::supports_canvas`]
93    /// reports. Capability is derived from the `page_size_pt` half of this seam,
94    /// not declared as a separate flag: a canvas backend is contractually
95    /// expected to pair this method with `page_size_pt` over the same page set.
96    fn render_rgba(&self, _page: usize, _scale: f32) -> Option<(u32, u32, Vec<u8>)> {
97        None
98    }
99
100    /// Schema-field geometry for the compiled session: [`RenderedRegion`]s
101    /// keyed on the quill schema address each field carries.
102    ///
103    /// A session-level query, not a render output: the geometry is a property of
104    /// the current compile, computed from already-resolved field placements
105    /// with no rasterization and no byte artifact. An interactive preview reads
106    /// it to lay out overlays / field cross-navigation over a `paint`-ed canvas;
107    /// a one-shot byte render carries it only on request
108    /// ([`RenderOptions::regions`](crate::RenderOptions)). Default empty: a
109    /// backend that places schema fields overrides this.
110    ///
111    /// Emit each content field's **first placement** (one region per page
112    /// that placement touches) plus one region per widget and per scalar
113    /// reference site. `field` is still not unique in the result: page
114    /// fragments, several scalar sites, or tracked content plus a bound
115    /// widget each surface independently ([`LiveSession::regions`] passes
116    /// them through; consumers group by `field`). Order deterministically:
117    /// widget regions first, then content regions in (page, field, site)
118    /// order.
119    fn regions(&self) -> Vec<RenderedRegion> {
120        Vec::new()
121    }
122
123    /// The schema field whose content is under a point: the forward
124    /// (click → field) direction of the region system. `x`/`y` are PDF points
125    /// with a **bottom-left** origin on `page`, the same convention as
126    /// [`RenderedRegion::rect`]. Unlike [`regions`](Self::regions), the
127    /// intent is that *every* placement answers, not just the first: one
128    /// concrete point identifies one drawn item, whose origin is unambiguous
129    /// however many times its field is placed.
130    ///
131    /// Default: hit-test [`regions`](Self::regions): complete only for a
132    /// backend whose regions enumerate every placement (widget-only backends
133    /// like pdfform), and empty when `regions` is. A backend whose regions
134    /// under-enumerate relative to its placements (first-placement-only
135    /// content emission, like Typst's) must override this with a real
136    /// document hit-test, or clicks on unenumerated placements dead-end.
137    fn field_at(&self, page: usize, x: f32, y: f32) -> Option<String> {
138        self.regions()
139            .into_iter()
140            .find(|r| r.contains(page, x, y))
141            .map(|r| r.field)
142    }
143
144    /// A point → **content position** in a content field: the fine-grained
145    /// twin of [`field_at`](Self::field_at) (which answers with the field
146    /// alone). `x`/`y` are PDF points, bottom-left origin on `page`. Returns
147    /// the field plus a USV offset into its `Content`, cluster-exact and
148    /// degrading to the containing segment's start on origin-less ink (see
149    /// [`ContentHit`]). `None` off all content ink, on a scalar/widget (no
150    /// content address), or when the backend maps no content. Default `None`:
151    /// a backend that carries a per-segment source map overrides this.
152    fn position_at(&self, _page: usize, _x: f32, _y: f32) -> Option<ContentHit> {
153        None
154    }
155
156    /// A content position → **caret rect** in a content field: the reverse of
157    /// [`position_at`](Self::position_at). `pos` is a USV offset into `field`'s
158    /// `Content`; the returned [`RenderedRegion`] is the box of the glyph the
159    /// caret sits at, page-indexed, with `span` collapsed to `[pos, pos]`.
160    /// `None` when `field` places no tracked content or `pos` maps to no drawn
161    /// glyph. Default `None`: overridden by a backend with a source map.
162    fn locate(&self, _field: &str, _pos: usize) -> Option<RenderedRegion> {
163        None
164    }
165
166    /// Non-fatal diagnostics of the **current compile**. A backend whose
167    /// compile emits warnings (Typst: font fallback, overfull pages, …)
168    /// overrides this to expose them; they swap with the compile on each
169    /// committed [`update`](Self::update), so a failed update keeps the last-good
170    /// compile's warnings alongside its document. Default empty: a backend
171    /// whose compile cannot warn leaves it.
172    fn warnings(&self) -> &[Diagnostic] {
173        &[]
174    }
175}
176
177/// Opaque, backend-backed live render session: a persistent compiler that
178/// serves reads (`render`, `paint` seams, `regions`) from its current compile
179/// and takes edits via [`update`](LiveSession::update). Reads between edits see
180/// a stable document (`update` is transactional, swapping the compile only on
181/// success) so immutability is an invariant between commits, not a type.
182///
183/// Geometry reads (`regions`, `position_at`, `locate`) resolve against the
184/// current compile. Anchoring a caret or selection across edits is the editor's
185/// job (its own transaction mapping): the session holds no change log and maps
186/// no positions forward; a consumer re-reads geometry after each committed
187/// [`update`](Self::update).
188pub struct LiveSession {
189    inner: Box<dyn SessionHandle>,
190    /// The schema authority the session was opened against: what lets
191    /// [`update`](Self::update) take a [`Document`] and compile it the way the
192    /// first compile was compiled. Held as the config rather than the whole
193    /// [`Quill`](crate::Quill) because the compile is a pure config read; the
194    /// font and package bytes stay with the backend that needed them.
195    config: QuillConfig,
196}
197
198impl LiveSession {
199    /// Born bound: a session cannot exist without the schema it renders. The
200    /// backend has the [`Quill`](crate::Quill) in hand inside
201    /// [`Backend::open`](crate::Backend::open), so binding costs it a
202    /// `source.config().clone()` and buys [`update`](Self::update) a document
203    /// verb whose plate is always compiled by *this* config: the pairing is
204    /// structural, not an obligation on the caller.
205    #[doc(hidden)]
206    pub fn new(inner: Box<dyn SessionHandle>, config: QuillConfig) -> Self {
207        Self { inner, config }
208    }
209
210    pub fn page_count(&self) -> usize {
211        self.inner.page_count()
212    }
213
214    /// Whether this session can paint pages to a canvas: the authoritative,
215    /// session-level capability. Derived directly from the canvas seam (a
216    /// painter exposes [`page_size_pt`](SessionHandle::page_size_pt) for its
217    /// pages), so there is no separate capability flag to keep in sync: a
218    /// canvas backend pairs [`render_rgba`](Self::render_rgba) with
219    /// `page_size_pt`, so this reflects what `paint` will do. A canvas-capable
220    /// backend with zero pages reports `false` (nothing to paint).
221    ///
222    /// For a pre-session estimate (no open session yet), see
223    /// [`formats_support_canvas`](crate::formats_support_canvas).
224    pub fn supports_canvas(&self) -> bool {
225        self.inner.page_count() > 0 && self.inner.page_size_pt(0).is_some()
226    }
227
228    /// Page dimensions in points, or `None` if `page` is out of range or the
229    /// backend has no canvas painter. Generalized canvas-preview seam; see
230    /// [`SessionHandle::page_size_pt`].
231    pub fn page_size_pt(&self, page: usize) -> Option<(f32, f32)> {
232        self.inner.page_size_pt(page)
233    }
234
235    /// Rasterize `page` to non-premultiplied RGBA8 at `scale`× 72 ppi, or `None`
236    /// if `page` is out of range or the backend has no canvas painter. A `Some`
237    /// result is a **complete** raster of the page (all content visible, no
238    /// caller-side compositing) per the per-backend contract on
239    /// [`SessionHandle::render_rgba`].
240    pub fn render_rgba(&self, page: usize, scale: f32) -> Option<(u32, u32, Vec<u8>)> {
241        self.inner.render_rgba(page, scale)
242    }
243
244    /// Schema-field geometry for the compiled session: each content field's
245    /// **first placement** (one [`RenderedRegion`] per page it touches), plus
246    /// one region per `field:`-bound widget and per direct scalar reference
247    /// site, keyed on the quill schema field path. A session-level query
248    /// computed without rendering bytes; an interactive preview reads it to
249    /// scroll to / highlight the focused field over a `paint`-ed canvas.
250    /// Empty for backends that place no schema fields.
251    ///
252    /// `field` is still not unique in the result: a placement breaking across
253    /// pages surfaces one fragment per page (a highlight covers continuation
254    /// pages), a scalar referenced at several plate sites surfaces each site,
255    /// and a field arising from both tracked content and a bound widget
256    /// surfaces both (overlapping rects that route to the same field). Group
257    /// by `field`; every entry routes to that field in the editor. Later
258    /// placements of one content value are **not** enumerated: for
259    /// point-driven lookup over any placement, use
260    /// [`field_at`](Self::field_at).
261    ///
262    /// Reflects the current compile; re-read after each committed
263    /// [`update`](Self::update) to pair a highlight box with the edit it shows.
264    pub fn regions(&self) -> Vec<RenderedRegion> {
265        self.inner.regions()
266    }
267
268    /// The whole-field highlight boxes for `field`: one union rect per page,
269    /// over the field's `span`-bearing content segments (the "highlight the
270    /// focused field" quantity). The convenience that owns the union
271    /// [`regions`](Self::regions) leaves derived: it keeps `regions()` as the
272    /// low-level disjoint truth and folds the span-filter + per-page
273    /// union here so no consumer reimplements it. Content only: a field placed
274    /// solely as a scalar reference or a bound widget carries no `span` and
275    /// yields nothing here; its box is a single [`regions`](Self::regions) rect.
276    /// Reflects the current compile, like `regions`. See [`crate::field_boxes`].
277    pub fn field_boxes(&self, field: &str) -> Vec<RenderedRegion> {
278        crate::field_boxes(&self.regions(), field)
279    }
280
281    /// The schema field whose content is under a point on `page`, the
282    /// forward (click → field) direction: hit-test a click against the
283    /// compiled document and get back the field address to focus in the
284    /// editor. `x`/`y` are PDF points with a **bottom-left** origin, the same
285    /// convention as [`RenderedRegion::rect`] (a canvas consumer applies the
286    /// inverse of the overlay transform it already uses for regions). Every
287    /// placement answers, not just the first surfaced by
288    /// [`regions`](Self::regions). `None` off any field's ink, out of range,
289    /// or for backends that place no schema fields.
290    pub fn field_at(&self, page: usize, x: f32, y: f32) -> Option<String> {
291        self.inner.field_at(page, x, y)
292    }
293
294    /// A point → **content position**, the fine-grained click direction:
295    /// hit-test a point and get back the field *and* a USV offset into its
296    /// `Content`, for placing a caret or mapping a selection into the content
297    /// model. `x`/`y` are PDF points, bottom-left origin, the same convention
298    /// as [`field_at`](Self::field_at). The offset is cluster-exact and
299    /// degrades to the containing segment's start on origin-less ink (list
300    /// markers, a code fence's interior). `None` off all content ink, on a
301    /// scalar/widget, or for backends with no content map. See [`ContentHit`].
302    ///
303    /// Resolves against the current compile; the editor owns the caret it
304    /// places and anchors it across later edits itself.
305    pub fn position_at(&self, page: usize, x: f32, y: f32) -> Option<ContentHit> {
306        self.inner.position_at(page, x, y)
307    }
308
309    /// A content position → **caret rect**, the reverse of
310    /// [`position_at`](Self::position_at): given a field and a USV offset into
311    /// its `Content`, return the box (page-indexed) to draw a caret at. `None`
312    /// when the field places no tracked content or the offset maps to no drawn
313    /// glyph. Resolves against the current compile.
314    pub fn locate(&self, field: &str, pos: usize) -> Option<RenderedRegion> {
315        self.inner.locate(field, pos)
316    }
317
318    /// Non-fatal diagnostics of the session's **current compile**: set at
319    /// `Backend::open` and refreshed by each committed [`update`](Self::update);
320    /// a failed update keeps the last-good compile *and* its warnings. Also
321    /// appended to [`RenderResult::warnings`] on each
322    /// [`render`](Self::render) call. Exposed for consumers (e.g. canvas
323    /// previews) that never call `render()`.
324    pub fn warnings(&self) -> &[Diagnostic] {
325        self.inner.warnings()
326    }
327
328    pub fn render(&self, opts: &RenderOptions) -> Result<RenderResult, RenderError> {
329        let mut result = self.inner.render(opts)?;
330        result
331            .warnings
332            .extend(self.inner.warnings().iter().cloned());
333        // The regions sidecar is attached here, at the wrapper, so every
334        // backend's one-shot render carries it without implementing anything
335        // beyond the `regions` accessor it already has.
336        if opts.regions {
337            result.regions = self.inner.regions();
338        }
339        Ok(result)
340    }
341
342    /// Recompile the session against new document data: the edit verb of a
343    /// live preview. Transactional: on `Err` the previous compile stays live,
344    /// so every read keeps serving the last-good document and its
345    /// [`warnings`](Self::warnings); on `Ok` the session serves the new
346    /// compile (warnings included) and the [`ChangeSet`] reports what
347    /// changed.
348    ///
349    /// `doc` is checked against the session's quill and compiled through the
350    /// same pipeline as the first compile ([`QuillConfig::compile_checked`]),
351    /// so an edit cannot reach the backend under a schema the session was not
352    /// opened against; a mismatch errors before anything is applied and leaves
353    /// the compile live like any other failed update.
354    pub fn update(&mut self, doc: &Document) -> Result<ChangeSet, RenderError> {
355        let json_data = self.config.compile_checked(doc)?;
356        self.inner.update(&json_data)
357    }
358
359    /// [`update`](Self::update) with the schema layer cut away: plate data
360    /// straight to the backend, no `$quill` check and no compile.
361    ///
362    /// For a backend's own acceptance tests, which drive a session against
363    /// synthetic plate data to exercise recompile and dirty-page behavior,
364    /// including data a schema would reject: the only lever that makes a
365    /// backend's compile fail on demand.
366    ///
367    /// Behind the `internal-test-seam` feature rather than `#[doc(hidden)]`
368    /// alone: the attribute hides a method from rustdoc and leaves it one
369    /// identifier away in every consumer build, and this one carries the
370    /// obligation [`update`](Self::update) exists to discharge. Off by default,
371    /// so the seam is absent unless a crate asks for it.
372    #[cfg(feature = "internal-test-seam")]
373    #[doc(hidden)]
374    pub fn update_data(&mut self, json_data: &serde_json::Value) -> Result<ChangeSet, RenderError> {
375        self.inner.update(json_data)
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::version::QuillReference;
383    use std::str::FromStr;
384
385    const QUILL_YAML: &str = "\
386quill:
387  name: memo
388  backend: typst
389  version: 1.0.0
390  description: Session test quill
391main:
392  fields:
393    subject:
394      type: plaintext
395";
396
397    /// The schema every session in these tests is born bound to.
398    fn config() -> QuillConfig {
399        QuillConfig::from_yaml(QUILL_YAML).expect("valid quill")
400    }
401
402    /// A document that pairs with [`config`], so `update` reaches the handle.
403    fn doc() -> Document {
404        Document::new(QuillReference::from_str("memo@1.0.0").unwrap())
405    }
406
407    /// A canvas-capable session: overrides the seam for `pages` pages.
408    struct CanvasHandle {
409        pages: usize,
410    }
411    impl SessionHandle for CanvasHandle {
412        fn render(&self, _: &RenderOptions) -> Result<RenderResult, RenderError> {
413            unimplemented!("render is not exercised by capability tests")
414        }
415        fn page_count(&self) -> usize {
416            self.pages
417        }
418        fn page_size_pt(&self, page: usize) -> Option<(f32, f32)> {
419            (page < self.pages).then_some((612.0, 792.0))
420        }
421    }
422
423    /// A non-canvas session: leaves the seam at its `None` defaults.
424    struct PlainHandle;
425    impl SessionHandle for PlainHandle {
426        fn render(&self, _: &RenderOptions) -> Result<RenderResult, RenderError> {
427            unimplemented!("render is not exercised by capability tests")
428        }
429        fn page_count(&self) -> usize {
430            1
431        }
432    }
433
434    /// A warning-emitting session: `warnings` reflects the current compile
435    /// (one warning per committed update), and `render` succeeds empty.
436    struct WarningHandle {
437        current: Vec<Diagnostic>,
438        applies: usize,
439    }
440    impl SessionHandle for WarningHandle {
441        fn render(&self, _: &RenderOptions) -> Result<RenderResult, RenderError> {
442            Ok(RenderResult::new(Vec::new(), crate::OutputFormat::Pdf))
443        }
444        fn page_count(&self) -> usize {
445            1
446        }
447        fn update(&mut self, _: &serde_json::Value) -> Result<ChangeSet, RenderError> {
448            self.applies += 1;
449            self.current = vec![Diagnostic::new(
450                Severity::Warning,
451                format!("warning of compile {}", self.applies),
452            )];
453            Ok(ChangeSet {
454                page_count: 1,
455                dirty_pages: vec![],
456            })
457        }
458        fn warnings(&self) -> &[Diagnostic] {
459            &self.current
460        }
461    }
462
463    /// `LiveSession::warnings` reflects the handle's current compile
464    /// (refreshed by a committed update) and `render` appends the same set to
465    /// `RenderResult::warnings`.
466    #[test]
467    fn warnings_track_current_compile() {
468        let open_warning = vec![Diagnostic::new(Severity::Warning, "open-time".to_string())];
469        let mut session = LiveSession::new(
470            Box::new(WarningHandle {
471                current: open_warning,
472                applies: 0,
473            }),
474            config(),
475        );
476        assert_eq!(session.warnings()[0].message, "open-time");
477
478        session.update(&doc()).unwrap();
479        assert_eq!(session.warnings()[0].message, "warning of compile 1");
480
481        let result = session.render(&RenderOptions::default()).unwrap();
482        assert_eq!(result.warnings[0].message, "warning of compile 1");
483    }
484
485    /// A handle that surfaces one content region, one hit, and one caret rect:
486    /// the geometry the wrapper passes straight through.
487    struct RegionHandle;
488    impl SessionHandle for RegionHandle {
489        fn render(&self, _: &RenderOptions) -> Result<RenderResult, RenderError> {
490            unimplemented!("render is not exercised by geometry tests")
491        }
492        fn page_count(&self) -> usize {
493            1
494        }
495        fn regions(&self) -> Vec<RenderedRegion> {
496            vec![RenderedRegion {
497                field: "subject".to_string(),
498                page: 0,
499                rect: [1.0, 2.0, 3.0, 4.0],
500                span: Some([0, 3]),
501            }]
502        }
503        fn position_at(&self, _: usize, _: f32, _: f32) -> Option<ContentHit> {
504            Some(ContentHit {
505                field: "subject".to_string(),
506                pos: 2,
507                granularity: Some(crate::HitGranularity::Cluster),
508            })
509        }
510        fn locate(&self, field: &str, pos: usize) -> Option<RenderedRegion> {
511            Some(RenderedRegion {
512                field: field.to_string(),
513                page: 0,
514                rect: [1.0, 2.0, 1.0, 4.0],
515                span: Some([pos, pos]),
516            })
517        }
518    }
519
520    /// `field_boxes` derives the whole-field box off the session's own
521    /// `regions()`.
522    #[test]
523    fn field_boxes_derives_off_regions() {
524        let session = LiveSession::new(Box::new(RegionHandle), config());
525        let boxes = session.field_boxes("subject");
526        assert_eq!(boxes.len(), 1, "one span-bearing region → one box");
527        assert_eq!(boxes[0].field, "subject");
528        // A field with no span-bearing region has no derived content box.
529        assert!(session.field_boxes("nope").is_empty());
530    }
531
532    #[test]
533    fn supports_canvas_derives_from_seam() {
534        // A session that exposes page geometry is canvas-capable…
535        let canvas = LiveSession::new(Box::new(CanvasHandle { pages: 2 }), config());
536        assert!(canvas.supports_canvas());
537        // …one that leaves the seam at its defaults is not…
538        let plain = LiveSession::new(Box::new(PlainHandle), config());
539        assert!(!plain.supports_canvas());
540        // …and a canvas backend with no pages has nothing to paint.
541        let empty = LiveSession::new(Box::new(CanvasHandle { pages: 0 }), config());
542        assert!(!empty.supports_canvas());
543    }
544}