Skip to main content

quillmark_core/
session.rs

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