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