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}