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}