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}