Skip to main content

quillmark_core/
region.rs

1//! Schema-field geometry, queried from a compiled
2//! [`LiveSession`](crate::LiveSession) via
3//! [`regions`](crate::LiveSession::regions) and
4//! [`field_at`](crate::LiveSession::field_at).
5//!
6//! A region ties a rectangle on the rendered page to the **quill schema field**
7//! that produced it: the address the document author already uses to refer to
8//! that field (the same address the Typst plate reads as `data.*` and the
9//! pdfform binder resolves against `compile_data`). The two directions a
10//! consumer navigates get two queries: `regions` answers *field → rectangle*
11//! (scroll to / highlight the focused field), `field_at` answers *point →
12//! field* (click a rendered field → focus it in the editor).
13//!
14//! Three producers feed regions, all keyed on the schema path:
15//!
16//! - **Content fields** (a richtext body, a `richtext[]` element, a card's
17//!   content field) are tracked by the **spans** their glyphs carry: the
18//!   backend evaluates each one's value at its own generated call site and
19//!   records the site's byte window, so every glyph of that content resolves
20//!   back to its field, through *any* placement context, including a package
21//!   that rebuilds the content (a `show`-rule pass that captures paragraphs
22//!   into a state buffer and re-emits them), because the origin rides the
23//!   glyph, not a sibling marker a rebuild could drop. A field that is blank
24//!   or draws nothing (an empty or whitespace-only body) has no inked extent
25//!   to bound and surfaces no region: present-but-empty is not the same as
26//!   placed.
27//! - **Direct scalar references**, every `data.<field>` / `data.at("field")`
28//!   expression in the plate is its own tracked site: the interpolated
29//!   value's glyphs carry a span at or around that reference expression. A
30//!   scalar shown in both a header and a footer surfaces both sites, because
31//!   two source expressions are two origins; a reference wrapped in an
32//!   expression (`#upper(data.subject)`) attributes the whole expression's
33//!   ink to the field as long as it is the expression's only reference. Not
34//!   tracked: an expression mixing several fields (`data.from + ", " + rank`
35//!   has no single owner), a value laundered through an intermediate binding
36//!   (`#let s = data.x` … `#s`), and card scalars read from the per-card
37//!   loop variable (`card.from` is *one* expression site shared by every card
38//!   instance, span data holds no per-instance identity; bind a widget for
39//!   those).
40//! - **Form-field widgets** carry a schema path explicitly: pdfform binds it
41//!   from the form mapping; a Typst `form-field` binds it from its `field:`
42//!   argument. A widget that binds none produces **no** region: its backend
43//!   identifier (the `/T` widget name) is not a schema address, so there is
44//!   nothing for a consumer to route to. Only schema-addressable fields surface
45//!   a region.
46//!
47//! **First placement only.** A content value placed at two sites surfaces one
48//! region set (its first placement's) because span data cannot distinguish
49//! "package chrome interrupting one placement" from "a second placement of
50//! the same value", and a spanning union would claim the ink between them.
51//! The first placement is one region per page it touches, in page order, so
52//! highlighting covers continuation pages, page marginals (headers, footers,
53//! page numbers) between one page's body and the next's do not end it, only a
54//! same-page interruption does: foreign ink within a page (a rebuild's
55//! numbering chrome) shrinks the region to the placement's true start rather
56//! than lying about extent. `field` is still not unique in the
57//! result: page fragments, several scalar reference sites, or tracked content
58//! plus a bound widget each surface independently.
59//! [`LiveSession::regions`](crate::LiveSession::regions) passes the backend's
60//! entries through; consumers group by `field`. Later placements stay
61//! reachable point-wise: [`field_at`](crate::LiveSession::field_at) resolves
62//! a click on *any* placement, since one concrete point identifies one drawn
63//! item whose origin is unambiguous.
64//!
65//! Regions are primarily a session-level query: the geometry is a property of
66//! the current compile, re-read from the session per edit without producing
67//! any byte artifact, the interactive-preview path (overlays over a
68//! `paint`-ed canvas) reads it that way. A one-shot byte render carries the
69//! same sidecar only on request ([`RenderOptions::regions`](crate::RenderOptions))
70//! for consumers without a live session (static SVG overlays, PDF
71//! post-processing, CI coverage probes). Either way regions are an overlay
72//! sidecar, never a compositing input: every canvas backend hands back a
73//! complete page raster, so nothing about the picture depends on reading a
74//! region. Empty for backends that place no schema fields.
75
76/// One schema field placement's extent on a rendered page.
77///
78/// `rect` is `[x0, y0, x1, y1]` in PDF points with a **bottom-left** origin:
79/// the same final geometry the stamp spine writes to the widget `/Rect`, so the
80/// region and the rendered field describe the identical box.
81///
82/// `field` is **not** unique within the `Vec` that
83/// [`LiveSession::regions`](crate::LiveSession::regions) returns: a content
84/// field breaks into one entry **per segment** (paragraph, heading, whole code
85/// fence) and per page each segment touches, a scalar referenced at several
86/// plate sites yields one per site, and tracked content plus a bound widget
87/// yields both. Consumers group by `field`; every entry routes to that field.
88/// The whole-field box is **derived**, the union of a page's `span`-bearing
89/// segment rects, so inter-paragraph whitespace stays uncovered; the
90/// [`field_boxes`] helper (and
91/// [`LiveSession::field_boxes`](crate::LiveSession::field_boxes)) owns that
92/// union so consumers need not reimplement it.
93#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
94#[serde(rename_all = "camelCase")]
95#[non_exhaustive]
96pub struct RenderedRegion {
97    /// The field's plate-space schema address as the backend keys it:
98    /// `"signature_block"` or `"$cards.<kind>.<ordinal>.<field>"` (a per-kind
99    /// ordinal). This is the backend-native form; a binding that owns the
100    /// document's card kinds translates it to a canonical
101    /// [`DocPath`] at its boundary
102    /// ([`plate_addr_to_doc_path`]), so its consumers see one absolute-index
103    /// grammar. A core consumer reading `RenderedRegion` directly sees the
104    /// plate-space form.
105    pub field: String,
106    /// 0-based page index.
107    pub page: usize,
108    /// `[x0, y0, x1, y1]`, PDF points, bottom-left origin.
109    pub rect: [f32; 4],
110    /// The content slice this box covers: USV `[start, end)` into the field's
111    /// `Content` for content ink (one segment's range), `None` for a scalar
112    /// reference site or a widget, geometry with no content address. Additive
113    /// and optional: omitted from the wire when `None`.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub span: Option<[usize; 2]>,
116}
117
118impl RenderedRegion {
119    /// A geometry entry with no content address: a scalar reference site or a
120    /// widget. Content ink adds its slice with [`with_span`](Self::with_span).
121    pub fn new(field: String, page: usize, rect: [f32; 4]) -> Self {
122        Self {
123            field,
124            page,
125            rect,
126            span: None,
127        }
128    }
129
130    /// Set [`span`](Self::span), the USV `[start, end)` this box covers.
131    pub fn with_span(mut self, span: [usize; 2]) -> Self {
132        self.span = Some(span);
133        self
134    }
135
136    /// Whether the point (`x`, `y`, PDF points, bottom-left origin) on `page`
137    /// falls inside this region, edges inclusive. The one point-in-region
138    /// predicate every `field_at` hit-test shares, so a click at a region
139    /// border resolves identically everywhere.
140    pub fn contains(&self, page: usize, x: f32, y: f32) -> bool {
141        self.page == page
142            && self.rect[0] <= x
143            && x <= self.rect[2]
144            && self.rect[1] <= y
145            && y <= self.rect[3]
146    }
147}
148
149/// The whole-field highlight boxes for `field`, derived from a region set: one
150/// union rect per page, over that field's **`span`-bearing** (content) regions.
151///
152/// This owns the subtle part [`regions`](crate::LiveSession::regions) leaves to
153/// consumers, filter by field, keep only the segment rects that carry a `span`,
154/// union per page, inherit first-placement-only from the input, so a
155/// "highlight the focused field" consumer never reimplements it and cannot
156/// reintroduce the field-level union the disjointness invariant exists to
157/// prevent (the input is already striped; this unions the *bounding* box per
158/// page, so inter-paragraph whitespace still is not a separate box but the
159/// derived rect does bound it). Pass the output of
160/// [`LiveSession::regions`](crate::LiveSession::regions) (or a one-shot
161/// [`RenderOptions::regions`](crate::RenderOptions) sidecar); the convenience
162/// [`LiveSession::field_boxes`](crate::LiveSession::field_boxes) reads the
163/// session's own.
164///
165/// **Content only.** A scalar-reference site or a widget carries no `span`
166/// ([`RenderedRegion::span`] is `None`), so a field placed *only* as a scalar
167/// reference or a bound widget yields an empty result here: its highlight box
168/// is a single region's `rect`, read straight from the region set with no
169/// derivation. Each returned region carries the union `span`
170/// (`[min start, max end)` over the page's contributing segments);
171/// `page`-ascending.
172pub fn field_boxes(regions: &[RenderedRegion], field: &str) -> Vec<RenderedRegion> {
173    let mut by_page: Vec<RenderedRegion> = Vec::new();
174    for r in regions
175        .iter()
176        .filter(|r| r.field == field && r.span.is_some())
177    {
178        let span = r.span.expect("filtered to span-bearing");
179        match by_page.iter_mut().find(|acc| acc.page == r.page) {
180            Some(acc) => {
181                acc.rect[0] = acc.rect[0].min(r.rect[0]);
182                acc.rect[1] = acc.rect[1].min(r.rect[1]);
183                acc.rect[2] = acc.rect[2].max(r.rect[2]);
184                acc.rect[3] = acc.rect[3].max(r.rect[3]);
185                let s = acc.span.expect("union region carries a span");
186                acc.span = Some([s[0].min(span[0]), s[1].max(span[1])]);
187            }
188            None => by_page.push(RenderedRegion {
189                field: r.field.clone(),
190                page: r.page,
191                rect: r.rect,
192                span: Some(span),
193            }),
194        }
195    }
196    by_page.sort_by_key(|r| r.page);
197    by_page
198}
199
200// ── Address translation: plate-space geometry ⇄ DocPath ─────────────────────
201//
202// A backend keys a region on the **plate-space** address its compiled plate
203// composes (`$path` = `$cards.<kind>.<ordinal>.`, `crates/backends/typst`), a
204// grammar with a `$cards` sigil, dot separators, and **per-kind ordinals**.
205// That grammar is the template-author contract inside the plate and stays
206// there; it must not cross to a consumer, which speaks one canonical
207// [`DocPath`]. The session owns the translation, resolving the per-kind ordinal
208// to the document-array absolute index (and back) against the ordered card
209// kinds of the current compile, so `regions` / `fieldAt` / `positionAt` /
210// `locate` speak `DocPath`, never `$cards.` ordinals.
211
212use crate::path::{DocPath, DocSeg};
213
214/// The absolute document-array index of the `ord`-th (0-based) card of `kind`,
215/// scanning `card_kinds` (the current compile's ordered card kinds; `None` is a
216/// kindless card) in order. `None` when fewer than `ord + 1` cards of that kind
217/// exist.
218fn abs_card_index(card_kinds: &[Option<&str>], kind: &str, ord: usize) -> Option<usize> {
219    card_kinds
220        .iter()
221        .enumerate()
222        .filter(|(_, k)| **k == Some(kind))
223        .nth(ord)
224        .map(|(i, _)| i)
225}
226
227/// The per-kind ordinal of the card at absolute index `abs`: how many cards of
228/// the same kind precede it, matching the plate's `emit_cards` counter. `None`
229/// when `abs` is out of range or the card is kindless.
230fn per_kind_ordinal(card_kinds: &[Option<&str>], abs: usize) -> Option<usize> {
231    let kind = (*card_kinds.get(abs)?)?;
232    Some(
233        card_kinds[..abs]
234            .iter()
235            .filter(|k| **k == Some(kind))
236            .count(),
237    )
238}
239
240/// Rewrite each region's plate-space `field` to its [`DocPath`] string: the
241/// translation [`RenderedRegion`] puts at a binding boundary. One funnel for
242/// every region a binding hands out, render sidecar and session query alike, so
243/// a consumer never sees the two address spaces mixed. An address outside the
244/// geometry grammar keeps its original string.
245pub fn regions_to_doc_path(
246    mut regions: Vec<RenderedRegion>,
247    card_kinds: &[Option<&str>],
248) -> Vec<RenderedRegion> {
249    for region in &mut regions {
250        if let Some(path) = plate_addr_to_doc_path(&region.field, card_kinds) {
251            region.field = path.to_string();
252        }
253    }
254    regions
255}
256
257/// Translate a backend plate-space geometry address into a canonical
258/// [`DocPath`], resolving the per-kind ordinal to the absolute card index via
259/// `card_kinds`. The grammar handled is exactly what geometry emits: `$body`
260/// (main body), a bare `<field>` (main field), `$cards.<kind>.<ord>.<field>`
261/// (card field), and `$cards.<kind>.<ord>.$body` (card body). `None` for an
262/// address outside that grammar or one naming a card the kind list cannot
263/// place: the caller keeps the original string.
264pub fn plate_addr_to_doc_path(addr: &str, card_kinds: &[Option<&str>]) -> Option<DocPath> {
265    if addr == "$body" {
266        return Some(DocPath::main_body());
267    }
268    if let Some(rest) = addr.strip_prefix("$cards.") {
269        let mut it = rest.splitn(3, '.');
270        let kind = it.next()?;
271        let ord: usize = it.next()?.parse().ok()?;
272        let tail = it.next()?;
273        let abs = abs_card_index(card_kinds, kind, ord)?;
274        let card = DocPath::card(Some(kind), abs);
275        return Some(if tail == "$body" {
276            card.body()
277        } else {
278            card.field(tail)
279        });
280    }
281    // A plate-space bare main field (`subject`) roots at `main` in `DocPath`
282    // space (`main.subject`), so a consumer always receives a parsed, rooted
283    // path. An unrecognized `$`-token (never a main field) does not translate.
284    if addr.starts_with('$') {
285        return None;
286    }
287    Some(DocPath::main().field(addr))
288}
289
290/// Translate a canonical [`DocPath`] geometry address back to the backend
291/// plate-space form (`main.body` → `$body`, `cards.<kind>[<abs>].<field>` →
292/// `$cards.<kind>.<ord>.<field>`), resolving the absolute card index to its
293/// per-kind ordinal via `card_kinds`. `None` when the path is not a geometry
294/// address (a document-model shape geometry never keys) or names a card the
295/// kind list cannot place. The inverse of [`plate_addr_to_doc_path`], for the
296/// `field`-taking queries (`locate`, `fieldBoxes`).
297pub fn doc_path_to_plate_addr(path: &DocPath, card_kinds: &[Option<&str>]) -> Option<String> {
298    match path.segs() {
299        [DocSeg::Main, DocSeg::Body] => Some("$body".to_string()),
300        [DocSeg::Main, DocSeg::Field { name }] => Some(name.clone()),
301        [DocSeg::Card {
302            kind: Some(kind),
303            index,
304        }, rest @ ..] => {
305            // The path must actually name the card that sits at `index`.
306            if card_kinds.get(*index).copied().flatten() != Some(kind.as_str()) {
307                return None;
308            }
309            let ord = per_kind_ordinal(card_kinds, *index)?;
310            match rest {
311                [DocSeg::Field { name }] => Some(format!("$cards.{kind}.{ord}.{name}")),
312                [DocSeg::Body] => Some(format!("$cards.{kind}.{ord}.$body")),
313                _ => None,
314            }
315        }
316        _ => None,
317    }
318}
319
320/// How precisely a [`ContentHit::pos`] resolved: the marker a caret UI reads to
321/// decide whether to trust the offset. The value is never sub-cluster; the two
322/// variants distinguish the finest this API offers from the segment floor it
323/// degrades to.
324#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
325#[serde(rename_all = "camelCase")]
326#[non_exhaustive]
327pub enum HitGranularity {
328    /// Cluster-exact: `pos` is the first content char of the grapheme cluster
329    /// under the point. The finest resolution: a char that escaped to several
330    /// generated bytes (`*`→`\*`, `你`→3, the `//`→`\/\/` coupling) still floors
331    /// to its cluster's first char, so this is *not* sub-character. A caret UI
332    /// can place the caret at `pos` directly.
333    Cluster,
334    /// Segment-floored: the point landed on origin-less ink (list markers,
335    /// numbering, a multi-line code fence's interior: spans that resolve to no
336    /// single run), so `pos` degraded to the containing segment's content start
337    /// rather than a wrong finer position. A caret UI should treat `pos` as the
338    /// segment it selected, not a within-segment caret.
339    Segment,
340}
341
342/// A resolved point → content position: the schema field a click landed in and
343/// the USV offset into that field's `Content`. The forward
344/// [`position_at`](crate::LiveSession::position_at) direction, paired with
345/// [`locate`](crate::LiveSession::locate) (content position → caret rect).
346///
347/// `pos` is **cluster-exact, not sub-character**: a hit inside a char that
348/// escaped to several generated bytes (`*`→`\*`, `你`→3, the `//`→`\/\/`
349/// coupling) floors to that cluster's first content char. A click on
350/// origin-less ink (list markers, numbering, a multi-line code fence's interior:
351/// spans that resolve to no single run) degrades to the containing segment's
352/// content start rather than a wrong finer position, and a click off all content
353/// ink resolves to nothing. [`granularity`](Self::granularity) reports which of
354/// those two happened, so a caret UI need not guess.
355#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
356#[serde(rename_all = "camelCase")]
357#[non_exhaustive]
358pub struct ContentHit {
359    /// The content field's schema path (same address space as
360    /// [`RenderedRegion::field`]).
361    pub field: String,
362    /// USV offset into the field's `Content`.
363    pub pos: usize,
364    /// Whether [`pos`](Self::pos) is cluster-exact or floored to the segment
365    /// start ([`HitGranularity`]). `None` when the backend does not report it (a
366    /// hit straight from a backend with no source map, or an older wire payload).
367    /// Additive-optional: omitted from the wire when `None`.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub granularity: Option<HitGranularity>,
370}
371
372impl ContentHit {
373    /// A hit whose precision the backend does not report. A backend with a
374    /// source map adds it with [`with_granularity`](Self::with_granularity).
375    pub fn new(field: String, pos: usize) -> Self {
376        Self {
377            field,
378            pos,
379            granularity: None,
380        }
381    }
382
383    /// Set [`granularity`](Self::granularity).
384    pub fn with_granularity(mut self, granularity: HitGranularity) -> Self {
385        self.granularity = Some(granularity);
386        self
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn region_round_trips_through_json() {
396        let region = RenderedRegion {
397            field: "full_name".to_string(),
398            page: 0,
399            rect: [180.0, 715.0, 520.0, 735.0],
400            span: Some([12, 34]),
401        };
402        let json = serde_json::to_string(&region).unwrap();
403        assert!(json.contains("\"field\":\"full_name\""), "{json}");
404        assert!(json.contains("\"span\":[12,34]"), "{json}");
405        let back: RenderedRegion = serde_json::from_str(&json).unwrap();
406        assert_eq!(back, region);
407    }
408
409    /// `span` is omitted when `None` and defaults back on read: the
410    /// additive-optional discipline that lets a scalar/widget region (no content
411    /// address) parse the same as a content region carrying a span.
412    #[test]
413    fn optional_span_omitted_when_none() {
414        let region = RenderedRegion {
415            field: "subject".to_string(),
416            page: 0,
417            rect: [1.0, 2.0, 3.0, 4.0],
418            span: None,
419        };
420        let json = serde_json::to_string(&region).unwrap();
421        assert!(!json.contains("span"), "scalar region omits span: {json}");
422        let back: RenderedRegion = serde_json::from_str(&json).unwrap();
423        assert_eq!(back, region);
424    }
425
426    #[test]
427    fn content_hit_round_trips_through_json() {
428        let hit = ContentHit {
429            field: "body".to_string(),
430            pos: 42,
431            granularity: Some(HitGranularity::Cluster),
432        };
433        let json = serde_json::to_string(&hit).unwrap();
434        assert!(json.contains("\"field\":\"body\"") && json.contains("\"pos\":42"));
435        assert!(json.contains("\"granularity\":\"cluster\""), "{json}");
436        let back: ContentHit = serde_json::from_str(&json).unwrap();
437        assert_eq!(back, hit);
438
439        // The segment-floored variant serializes to its own tag, so a caret UI
440        // can tell a trusted cluster offset from a floored one.
441        let seg = ContentHit {
442            field: "body".to_string(),
443            pos: 7,
444            granularity: Some(HitGranularity::Segment),
445        };
446        let json = serde_json::to_string(&seg).unwrap();
447        assert!(json.contains("\"granularity\":\"segment\""), "{json}");
448        assert_eq!(serde_json::from_str::<ContentHit>(&json).unwrap(), seg);
449    }
450
451    /// `granularity` omits when `None` and defaults back on read: the
452    /// additive-optional discipline, so a hit straight from a backend (no source
453    /// map) parses the same as one that reports it.
454    #[test]
455    fn content_hit_omits_optionals_when_none() {
456        let hit = ContentHit {
457            field: "body".to_string(),
458            pos: 42,
459            granularity: None,
460        };
461        let json = serde_json::to_string(&hit).unwrap();
462        assert!(
463            !json.contains("granularity"),
464            "unreported granularity omitted: {json}"
465        );
466        let back: ContentHit = serde_json::from_str(&json).unwrap();
467        assert_eq!(back, hit);
468    }
469
470    fn content(field: &str, page: usize, rect: [f32; 4], span: [usize; 2]) -> RenderedRegion {
471        RenderedRegion {
472            field: field.to_string(),
473            page,
474            rect,
475            span: Some(span),
476        }
477    }
478
479    /// `field_boxes` unions a page's span-bearing segment rects into one box and
480    /// ignores other fields: the whole-field highlight a consumer would
481    /// otherwise derive by hand. The union `span` bounds `[min start, max end)`,
482    /// and each page gets its own box, page-ascending.
483    #[test]
484    fn field_boxes_unions_span_bearing_segments_per_page() {
485        let regions = vec![
486            content("$body", 0, [10.0, 700.0, 200.0, 720.0], [0, 12]),
487            content("$body", 0, [10.0, 660.0, 260.0, 680.0], [13, 40]),
488            content("$body", 1, [10.0, 700.0, 150.0, 720.0], [41, 55]),
489            content("subject", 0, [10.0, 740.0, 90.0, 752.0], [0, 5]),
490        ];
491        let boxes = field_boxes(&regions, "$body");
492        assert_eq!(boxes.len(), 2, "one box per page $body touches");
493        assert_eq!(boxes[0].page, 0);
494        assert_eq!(boxes[0].rect, [10.0, 660.0, 260.0, 720.0], "page-0 union");
495        assert_eq!(boxes[0].span, Some([0, 40]), "page-0 union span");
496        assert_eq!(boxes[1].page, 1);
497        assert_eq!(boxes[1].rect, [10.0, 700.0, 150.0, 720.0]);
498    }
499
500    /// A field placed only as a scalar reference or widget (no `span`) yields no
501    /// derived content box: its highlight is a single region's `rect`, read
502    /// straight from the set.
503    #[test]
504    fn field_boxes_empty_for_span_less_field() {
505        let regions = vec![RenderedRegion {
506            field: "subject".to_string(),
507            page: 0,
508            rect: [10.0, 740.0, 90.0, 752.0],
509            span: None,
510        }];
511        assert!(field_boxes(&regions, "subject").is_empty());
512    }
513
514    // ── Plate-space ⇄ DocPath translation ────────────────────────────────────
515
516    /// Two `note` cards interleaved with one `annotation`: the per-kind ordinal
517    /// is not the absolute index once kinds interleave, so the two grammars
518    /// genuinely differ and the kind list is load-bearing.
519    const KINDS: &[Option<&str>] = &[Some("note"), Some("annotation"), Some("note")];
520
521    fn to_doc(addr: &str) -> Option<String> {
522        plate_addr_to_doc_path(addr, KINDS).map(|p| p.to_string())
523    }
524    fn to_plate(path: &str) -> Option<String> {
525        doc_path_to_plate_addr(&path.parse().unwrap(), KINDS)
526    }
527
528    #[test]
529    fn plate_to_docpath_resolves_the_absolute_index() {
530        // The 2nd `note` (ordinal 1) sits at absolute index 2.
531        assert_eq!(to_doc("$cards.note.1.on").as_deref(), Some("cards.note[2].on"));
532        // The 1st `note` (ordinal 0) is absolute 0; the `annotation` is absolute 1.
533        assert_eq!(to_doc("$cards.note.0.on").as_deref(), Some("cards.note[0].on"));
534        assert_eq!(
535            to_doc("$cards.annotation.0.text").as_deref(),
536            Some("cards.annotation[1].text")
537        );
538        // Bodies and main.
539        assert_eq!(to_doc("$body").as_deref(), Some("main.body"));
540        assert_eq!(
541            to_doc("$cards.note.1.$body").as_deref(),
542            Some("cards.note[2].body")
543        );
544        // A plate-space bare main field roots at `main` in DocPath space.
545        assert_eq!(to_doc("signature_block").as_deref(), Some("main.signature_block"));
546    }
547
548    #[test]
549    fn docpath_to_plate_is_the_inverse() {
550        for plate in [
551            "$body",
552            "signature_block",
553            "$cards.note.0.on",
554            "$cards.note.1.on",
555            "$cards.annotation.0.text",
556            "$cards.note.1.$body",
557        ] {
558            let doc = to_doc(plate).unwrap();
559            assert_eq!(to_plate(&doc).as_deref(), Some(plate), "round-trip {plate}");
560        }
561    }
562
563    #[test]
564    fn translation_rejects_unplaceable_and_foreign_shapes() {
565        // A 3rd `note` (ordinal 2) does not exist: only two notes.
566        assert_eq!(to_doc("$cards.note.2.on"), None);
567        // A DocPath whose kind disagrees with the slot does not translate back.
568        assert_eq!(
569            doc_path_to_plate_addr(&"cards.annotation[0].x".parse().unwrap(), KINDS),
570            None
571        );
572        // A document-model shape geometry never keys (nested main field).
573        assert_eq!(
574            doc_path_to_plate_addr(&"recipients[0].name".parse().unwrap(), KINDS),
575            None
576        );
577    }
578}