Skip to main content

quillmark_content/
model.rs

1//! The `Content` content model — one text sequence per field carrying line
2//! attributes, anchored marks, and embedded islands, over a single coordinate
3//! space of Unicode scalar values (Rust `char`).
4//!
5//! This is the freeze (issue #831): the mark set, the three
6//! normalization rules, and the invariants are what canonical serialization
7//! commits to. Everything an editor disagrees on (edge-expand,
8//! adjacent-merge-at-insertion) is *not* encoded — the model only ever stores
9//! the resulting range, so the stored form is identical whatever the editor
10//! did.
11
12use crate::normalize::is_bidi_char;
13use serde_json::Value as JsonValue;
14
15/// A position in a [`Content`], counted in Unicode scalar values (USV) — never
16/// bytes, never UTF-16 units. One astral char is 1 USV / 4 UTF-8 bytes / 2
17/// UTF-16 units. Conversions to/from the JS (UTF-16) and Rust (UTF-8)
18/// boundaries live in [`crate::usv`].
19pub type Usv = usize;
20
21/// U+FFFC OBJECT REPLACEMENT CHARACTER — the single-USV slot an island occupies
22/// in the content. One slot per island; every slot has a backing island. A stray
23/// slot (or a slot with no island) is an invariant violation.
24pub const ISLAND_SLOT: char = '\u{FFFC}';
25
26/// One content field as a content: the text plus the structure that rides on it.
27///
28/// Invariants (established once by import normalization, checked by
29/// [`Content::validate`]): the text holds no `\r` and no bidi controls; the
30/// count of [`ISLAND_SLOT`] equals `islands.len()`; `lines.len()` equals the
31/// number of `\n`-separated segments; marks are normalized (sorted, unioned).
32#[derive(Debug, Clone, PartialEq)]
33pub struct Content {
34    /// The content. `\n` is a line boundary; [`ISLAND_SLOT`] is an island slot.
35    pub text: String,
36    /// One entry per `\n`-separated segment of `text`, in order. The line tree
37    /// is *derived* from this flat list plus each line's `containers` path — it
38    /// is never stored, so a split/join is a single-char edit with no identity
39    /// crisis (there are no paragraph IDs).
40    pub lines: Vec<Line>,
41    /// Marks over char ranges, kept normalized: sorted by
42    /// `(start, end, kind-ord, attrs)`, same-kind formatting marks unioned.
43    pub marks: Vec<Mark>,
44    /// One entry per [`ISLAND_SLOT`], in slot order (ascending char position).
45    pub islands: Vec<Island>,
46}
47
48/// A line's attributes: its block role plus the container path it sits in.
49#[derive(Debug, Clone, PartialEq)]
50pub struct Line {
51    pub kind: LineKind,
52    /// Ancestor containers, outermost first. A multi-paragraph list item is two
53    /// `Para` lines sharing one `[ListItem]` path; a paragraph in a quote in a
54    /// list item is `[ListItem, Quote]`.
55    pub containers: Vec<Container>,
56    /// Whether this line continues the previous line's *block* across a hard
57    /// line break (no paragraph break between), rather than starting a new
58    /// block. `false` = a new block (paragraph spacing on either side); `true` =
59    /// a within-block line break (markdown hard break; consecutive lines of one
60    /// code fence). The first line is always `false`. This is what keeps a hard
61    /// break (backend `#linebreak()`) distinct from a paragraph boundary through
62    /// the freeze, and what groups a code fence's lines without an
63    /// adjacency heuristic.
64    pub continues: bool,
65}
66
67/// The block role of a line. The tree between lines is inferred: two adjacent
68/// lines with equal `kind`+`containers` are two blocks of that role (e.g. two
69/// paragraphs), never one.
70///
71/// **Open**, on the same terms as [`MarkKind`]: an unrecognized role round-trips
72/// as [`LineKind::Unknown`] and *projects* as [`LineKind::Para`], so adding a
73/// block construct (a callout, a footnote, a task item) is not a document schema
74/// event — an older reader renders the future construct as a plain paragraph
75/// instead of refusing the whole document, and the opaque tag+attrs still reach a
76/// reader that understands them (`DOCUMENT_STORAGE.md` § Open vocabularies).
77#[derive(Debug, Clone, PartialEq)]
78pub enum LineKind {
79    Para,
80    /// ATX/Setext heading, level 1..=6.
81    Heading {
82        level: u8,
83    },
84    /// A line of a code block. `lang` is the (sanitized) info string, shared by
85    /// every line of the same block.
86    Code {
87        lang: Option<String>,
88    },
89    /// A block-level island: the line's sole content is one [`ISLAND_SLOT`].
90    Island,
91    /// A thematic break (`---`/`***`/`___`). The line carries no text — the
92    /// break is the line itself, parallel to how an island's content is its
93    /// one slot char.
94    Rule,
95    /// Open-set escape hatch — a block role this build does not know,
96    /// round-tripped opaque and projected as [`LineKind::Para`]. Carries
97    /// arbitrary text, like the `Para` it projects as, so no
98    /// [`LineKindMismatch`] constrains it.
99    Unknown {
100        tag: String,
101        attrs: JsonValue,
102    },
103}
104
105impl LineKind {
106    /// Whether the line projects as a paragraph: [`LineKind::Para`] itself, or an
107    /// unknown role, which every projection renders as one. For the tests a
108    /// `match` cannot serve — a `matches!(kind, Para)` that special-cases the
109    /// paragraph (suppressing an empty block, say) reads as complete but drops
110    /// the open arm, and the two emitters then drift on the construct neither
111    /// knows. An exhaustive `match` keeps listing both arms; the compiler polices
112    /// that one.
113    pub fn projects_as_para(&self) -> bool {
114        matches!(self, LineKind::Para | LineKind::Unknown { .. })
115    }
116}
117
118/// A container a line nests inside. The ancestor path is a `Vec<Container>`.
119///
120/// **Open**, on [`LineKind`]'s terms: an unrecognized container round-trips as
121/// [`Container::Unknown`] and projects *transparently* — its lines render at the
122/// enclosing level, with no prefix, no wrapper, and no grouping of their own.
123#[derive(Debug, Clone, PartialEq)]
124pub enum Container {
125    /// A list item. `ordered` distinguishes `1.` from `-`; `start` is the list's
126    /// first number (1 by default); `ordinal` is this item's 0-based index in
127    /// its list. Two *adjacent* lines belong to the same item iff their whole
128    /// container path (ordinals included) is equal — so a multi-paragraph item
129    /// is two lines sharing one `ListItem`, while the next item differs by
130    /// `ordinal`. (Identity is path **plus contiguity**: two sibling inner lists
131    /// under one outer item can produce equal first-item paths, distinguished
132    /// only by the non-adjacency of their runs.) Positional and deterministic —
133    /// no minted ids.
134    ListItem {
135        ordered: bool,
136        start: u64,
137        ordinal: u64,
138    },
139    /// A block quote. Adjacent lines sharing `[Quote]` are one multi-paragraph
140    /// quote; two adjacent separate quotes are not distinguished (they merge on
141    /// round-trip — a documented canonicalization).
142    Quote,
143    /// Open-set escape hatch — a container this build does not know, kept in the
144    /// path so it round-trips, transparent to both projections. Two adjacent
145    /// lines sit in the same one iff their whole `(tag, attrs)` is equal, the
146    /// path-plus-contiguity rule the known containers use.
147    Unknown {
148        tag: String,
149        attrs: JsonValue,
150    },
151}
152
153/// A mark over a char range `[start, end)`. `start == end` (zero-width) is legal
154/// only for [`MarkKind::Anchor`]; normalization drops zero-width formatting.
155#[derive(Debug, Clone, PartialEq)]
156pub struct Mark {
157    pub start: Usv,
158    pub end: Usv,
159    pub kind: MarkKind,
160}
161
162/// The mark set — **open**: an unknown kind round-trips as [`MarkKind::Unknown`],
163/// absorbed as a new *type*, never a changed semantics of a known one. Two
164/// algebra classes: formatting is a property of a range (two coincident are
165/// redundant); identity is a handle (two over the same range are two things).
166#[derive(Debug, Clone, PartialEq)]
167pub enum MarkKind {
168    // Formatting — round-trippable projection marks. `is_formatting()`.
169    Strong,
170    Emph,
171    Underline,
172    Strike,
173    Code,
174    Link {
175        url: String,
176    },
177    // Identity — a handle, not a property. Never merged, may be zero-width.
178    /// A comment thread or stable anchor, carried by id and rebased across
179    /// edits like any position. The id is caller-supplied, unique per `Content`,
180    /// opaque and invariant while the mark lives; positions rebase, the id never
181    /// does, and moved-and-rewritten text drops the mark whole
182    /// (`DOCUMENT_STORAGE.md` § Anchor-id identity). No markdown projection
183    /// (omitted on export; survives via diff-rebase).
184    Anchor {
185        id: String,
186    },
187    // Open-set escape hatch — an unknown mark type, round-tripped opaque.
188    Unknown {
189        tag: String,
190        attrs: JsonValue,
191    },
192}
193
194/// A structured object with no honest text encoding — a table, figure, or future
195/// embed — occupying one [`ISLAND_SLOT`] in the content.
196#[derive(Debug, Clone, PartialEq)]
197pub struct Island {
198    /// Deterministically minted, session-stable id — `isl-{n}` by import
199    /// position (`import::mint_island`). Part of the canonical form and thus
200    /// hash input; deterministic by contract, never ambient, so equal content
201    /// hashes equal (`DOCUMENT_STORAGE.md` § Island-id determinism). Edits keep
202    /// it stable rather than re-deriving it, so [`Content::validate`] enforces
203    /// uniqueness, not positional equality.
204    pub id: String,
205    /// Island type discriminator (`"table"`, `"image"`, …). Unknown types
206    /// round-trip opaque.
207    pub island_type: String,
208    /// Typed payload. Recursively key-sorted by normalization so it hashes
209    /// deterministically despite `serde_json`'s `preserve_order`.
210    pub props: JsonValue,
211    /// How faithfully the markdown projection can carry this island.
212    pub loss: Loss,
213}
214
215/// The markdown-projection loss class of an island — a **description** of how
216/// faithfully the projection carries it, for a consumer to surface (a caller
217/// warned that a form field silently dropped a table, issue #1043). It is not a
218/// switch: [`crate::export::to_markdown`] dispatches on
219/// [`Island::island_type`], never on this.
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221pub enum Loss {
222    /// Markdown carries it faithfully (round-trips identically).
223    Lossless,
224    /// Markdown carries an approximation (round-trips visibly, not identically).
225    Degraded,
226    /// No markdown encoding — what an island type with no projection carries,
227    /// and the safe default a decoder mints for an unrecognized loss class.
228    Unrepresentable,
229}
230
231impl MarkKind {
232    /// Formatting marks are a property of a range and union when coincident;
233    /// identity/unknown marks are handles and never merge (Spike-A rules).
234    pub fn is_formatting(&self) -> bool {
235        matches!(
236            self,
237            MarkKind::Strong
238                | MarkKind::Emph
239                | MarkKind::Underline
240                | MarkKind::Strike
241                | MarkKind::Code
242                | MarkKind::Link { .. }
243        )
244    }
245
246    /// Total order over kinds for the canonical sort tie-break, after
247    /// `(start, end)`. Stable across releases — part of the freeze.
248    pub fn ord(&self) -> u8 {
249        match self {
250            MarkKind::Strong => 0,
251            MarkKind::Emph => 1,
252            MarkKind::Underline => 2,
253            MarkKind::Strike => 3,
254            MarkKind::Code => 4,
255            MarkKind::Link { .. } => 5,
256            MarkKind::Anchor { .. } => 6,
257            MarkKind::Unknown { .. } => 7,
258        }
259    }
260
261    /// Attribute tie-break string, appended after `ord` in the canonical sort so
262    /// two marks that differ only in attrs order deterministically. Also the
263    /// grouping key for same-kind union (two formatting marks union only when
264    /// this matches — e.g. two `link`s union only at the same url).
265    pub fn attrs_key(&self) -> String {
266        match self {
267            MarkKind::Link { url } => url.clone(),
268            MarkKind::Anchor { id } => id.clone(),
269            MarkKind::Unknown { tag, attrs } => {
270                // Attrs sorted so the key is order-insensitive.
271                format!("{}\u{0}{}", tag, canonical_json_string(attrs))
272            }
273            _ => String::new(),
274        }
275    }
276}
277
278/// A `serde_json::Value` rendered to a string with object keys recursively
279/// sorted — order-insensitive, so it is a stable comparison/grouping key.
280fn canonical_json_string(v: &JsonValue) -> String {
281    serde_json::to_string(&sorted_value(v)).unwrap_or_default()
282}
283
284/// Rebuild `v` with every object's keys sorted, recursively. Pins island
285/// `props` (and unknown-mark attrs) against `preserve_order` leaking insertion
286/// order into the canonical bytes / content hash (Spike C carry-forward). For
287/// an owned tree, prefer [`sort_keys_owned`] — it reorders in place without
288/// cloning the leaves.
289pub(crate) fn sorted_value(v: &JsonValue) -> JsonValue {
290    match v {
291        JsonValue::Array(items) => JsonValue::Array(items.iter().map(sorted_value).collect()),
292        JsonValue::Object(map) => {
293            let mut keys: Vec<&String> = map.keys().collect();
294            keys.sort();
295            let mut out = serde_json::Map::with_capacity(map.len());
296            for k in keys {
297                out.insert(k.clone(), sorted_value(&map[k]));
298            }
299            JsonValue::Object(out)
300        }
301        other => other.clone(),
302    }
303}
304
305/// Whether every object in `v` already has its keys in ascending order,
306/// recursively — the cheap allocation-free check that lets a re-normalize skip
307/// rebuilding an already-canonical `props`/`attrs` tree via [`sorted_value`].
308/// Once normalized, an untouched tree stays sorted, so a per-keystroke
309/// re-normalize pays a scan instead of a full clone.
310pub(crate) fn is_value_key_sorted(v: &JsonValue) -> bool {
311    match v {
312        JsonValue::Array(items) => items.iter().all(is_value_key_sorted),
313        JsonValue::Object(map) => {
314            map.keys().zip(map.keys().skip(1)).all(|(a, b)| a <= b)
315                && map.values().all(is_value_key_sorted)
316        }
317        _ => true,
318    }
319}
320
321/// Put `v` in canonical key order, rebuilding it only when a key is actually out
322/// of order — an untouched tree (a pure text splice) stays sorted, so the
323/// per-keystroke path pays the scan and skips the deep clone.
324pub(crate) fn canonicalize_keys(v: &mut JsonValue) {
325    if !is_value_key_sorted(v) {
326        *v = sorted_value(v);
327    }
328}
329
330/// The owned twin of [`sorted_value`]: reorder every object's keys by **moving**
331/// each entry into a freshly key-sorted map, recursively. Same canonical result
332/// — the fixed struct keys land alphabetically and any already-sorted `props`/
333/// `attrs` re-sort to themselves — but the leaves (the `text` string, mark
334/// attrs, arrays) are moved rather than deep-cloned, so a tree built once by
335/// `to_value` is canonicalized without a second full clone. Re-sorting a new
336/// `serde_json::Map` (not sorting in place) keeps this independent of whether
337/// `serde_json`'s `preserve_order` feature is on in the crate graph.
338pub(crate) fn sort_keys_owned(v: JsonValue) -> JsonValue {
339    match v {
340        JsonValue::Array(items) => {
341            JsonValue::Array(items.into_iter().map(sort_keys_owned).collect())
342        }
343        JsonValue::Object(map) => {
344            let mut entries: Vec<(String, JsonValue)> = map.into_iter().collect();
345            entries.sort_by(|a, b| a.0.cmp(&b.0));
346            let mut out = serde_json::Map::with_capacity(entries.len());
347            for (k, child) in entries {
348                out.insert(k, sort_keys_owned(child));
349            }
350            JsonValue::Object(out)
351        }
352        other => other,
353    }
354}
355
356/// Ways a [`Content`] can violate its invariants. Returned by
357/// [`Content::validate`]; import normalization guarantees none of these.
358#[derive(Debug, Clone, PartialEq, Eq)]
359pub enum Invariant {
360    /// `\r` in the text (line endings must be normalized to `\n`).
361    CarriageReturn,
362    /// A bidi formatting control in the text.
363    BidiControl(char),
364    /// `island_slot_count != islands.len()`.
365    IslandSlotMismatch { slots: usize, islands: usize },
366    /// `lines.len() != newline_segment_count`.
367    LineCountMismatch { lines: usize, segments: usize },
368    /// A mark range runs past the content or is inverted (`start > end`).
369    MarkOutOfRange { start: Usv, end: Usv, len: Usv },
370    /// A zero-width formatting mark survived normalization.
371    ZeroWidthFormatting { at: Usv },
372    /// A heading level outside 1..=6.
373    BadHeadingLevel(u8),
374    /// The first line has `continues: true` (nothing precedes it to continue).
375    FirstLineContinues,
376    /// An [`MarkKind::Unknown`] reused a reserved built-in `type` name.
377    ReservedUnknownTag(String),
378    /// A [`LineKind::Unknown`] reused a reserved built-in `kind` name — its
379    /// serialization would parse back as the built-in, dropping its attrs.
380    ReservedUnknownLineKind(String),
381    /// A [`Container::Unknown`] reused a reserved built-in `container` name, the
382    /// same non-injectivity as [`Invariant::ReservedUnknownLineKind`].
383    ReservedUnknownContainer(String),
384    /// A formatting mark edge sits on a `\n` (normalization should have trimmed
385    /// it) — a hand-built content that skipped `normalize`.
386    MarkEdgeOnNewline { at: Usv },
387    /// A table island's `aligns` length differs from its column count (the
388    /// header width). `normalize` syncs `aligns` to the column count.
389    TableAlignsMismatch { aligns: usize, cols: usize },
390    /// A table island body row's width differs from the column count (the header
391    /// width). `normalize` pads short rows (and the header) to the widest.
392    TableRaggedRow { row: usize, width: usize, cols: usize },
393    /// A table cell's text carries a `\n` — cells are single-line (a newline
394    /// would break the exported table). `cell` is the flat header-then-rows
395    /// index; `normalize` rewrites the newline to a space.
396    TableCellNewline { cell: usize },
397    /// Two islands share an `id`. Ids are deterministic, session-stable
398    /// identities (hash input, so never ambient); import mints them by index so
399    /// they never collide, but a hand-built or round-tripped content can.
400    /// Downstream code that keys islands by id would otherwise silently pick the
401    /// wrong one. Uniqueness is the id invariant `validate` enforces — positional
402    /// equality is not, since edits keep an island's id stable across renumbers.
403    IslandIdCollision { id: String },
404    /// Two prose anchors share an `id`, or one carries the empty id. An anchor
405    /// id is a caller-supplied, opaque handle, unique per `Content` (hash input,
406    /// never ambient in the twin's sense; `DOCUMENT_STORAGE.md` § Anchor-id
407    /// identity). `RemoveAnchor { id }` retains-out *every* match, so a shared id
408    /// makes removing one destroy both; the empty id is a degenerate handle.
409    /// Scope is prose marks — cell anchors are outside the op surface.
410    AnchorIdCollision { id: String },
411    /// A table island's `header` prop is present but not a JSON array — it
412    /// cannot carry column cells. `normalize` rewrites a non-array header to an
413    /// empty array (a zero-column, content-free table).
414    TableHeaderNotArray,
415    /// A line's [`LineKind`] contradicts its text. Export trusts the kind and
416    /// never re-reads the segment, so an unchecked mismatch is silent text loss
417    /// (an `Island`-tagged prose line projects to its resolved island alone).
418    LineKindMismatch { line: usize, mismatch: LineKindMismatch },
419    /// A line's container path is nested deeper than [`MAX_NESTING_DEPTH`]. Both
420    /// emitters recurse one frame per container, so an unbounded path overflows
421    /// the stack; import caps it, and this is the same cap for the content that
422    /// never went through import (a decoded blob, a hand-built value).
423    NestingTooDeep {
424        line: usize,
425        depth: usize,
426        max: usize,
427    },
428}
429
430/// The way a line's text can contradict its [`LineKind`]. `Para` and `Heading`
431/// carry arbitrary text including slots (an inline image is a slot in a `Para`),
432/// so only the three kinds whose contract *names* their content constrain it.
433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
434pub enum LineKindMismatch {
435    /// [`LineKind::Island`] whose text is not exactly one [`ISLAND_SLOT`].
436    IslandNotOneSlot,
437    /// [`LineKind::Rule`] carrying text — the break is the line itself.
438    RuleNotEmpty,
439    /// [`LineKind::Code`] carrying an [`ISLAND_SLOT`]. A fence emits its text
440    /// verbatim, so the slot lands raw in the output and re-imports as nothing:
441    /// the island and its slot both vanish.
442    CodeHasSlot,
443}
444
445/// How a line's text contradicts `kind`, if it does — the single reading behind
446/// the [`Invariant::LineKindMismatch`] and
447/// [`ApplyError::LineKindMismatch`](crate::ops::ApplyError::LineKindMismatch)
448/// twins, so the validate-time and op-time checks cannot drift.
449pub fn line_kind_mismatch(kind: &LineKind, seg: &str) -> Option<LineKindMismatch> {
450    match kind {
451        LineKind::Island => {
452            let mut chars = seg.chars();
453            match (chars.next(), chars.next()) {
454                (Some(ISLAND_SLOT), None) => None,
455                _ => Some(LineKindMismatch::IslandNotOneSlot),
456            }
457        }
458        LineKind::Rule if !seg.is_empty() => Some(LineKindMismatch::RuleNotEmpty),
459        LineKind::Code { .. } if seg.contains(ISLAND_SLOT) => Some(LineKindMismatch::CodeHasSlot),
460        _ => None,
461    }
462}
463
464impl Content {
465    /// An empty content: one empty `Para` line, no marks, no islands.
466    pub fn empty() -> Self {
467        Content {
468            text: String::new(),
469            lines: vec![Line {
470                kind: LineKind::Para,
471                containers: Vec::new(),
472                continues: false,
473            }],
474            marks: Vec::new(),
475            islands: Vec::new(),
476        }
477    }
478
479    /// Total length in USV.
480    pub fn len_usv(&self) -> Usv {
481        self.text.chars().count()
482    }
483
484    /// Whether this content satisfies the `richtext(inline)` constraint: exactly
485    /// one `Para` line, sitting in no container, with no islands. A single line
486    /// can never `continues` (line 0 is always `false`), so that dimension is
487    /// implied. [`Content::empty`] is inline (one empty `Para`), so a blank or
488    /// zero-filled inline field passes.
489    pub fn is_inline(&self) -> bool {
490        self.islands.is_empty()
491            && self.lines.len() == 1
492            && self.lines[0].kind == LineKind::Para
493            && self.lines[0].containers.is_empty()
494    }
495
496    /// Whether this content satisfies the `plaintext` constraint: no marks, no
497    /// islands, and every line is a plain `Para` sitting in no container. It is
498    /// the multi-line generalization of [`is_inline`](Self::is_inline) (which
499    /// additionally pins the content to one line) with the mark/island exclusion
500    /// made explicit — a plaintext value carries prose the author navigates but
501    /// no formatting. `continues` is unconstrained: a lone `\n` may be a
502    /// within-paragraph break. [`Content::empty`] is plain.
503    ///
504    /// This is the plaintext analogue of `is_inline`, enforced at coercion and
505    /// validation with the `NotPlain` error; the distinguishing property of
506    /// plaintext over `richtext { marks: [] }` is the *literal* codec
507    /// ([`crate::import::from_plaintext`]), not this predicate.
508    pub fn is_plain(&self) -> bool {
509        self.marks.is_empty()
510            && self.islands.is_empty()
511            && self
512                .lines
513                .iter()
514                .all(|l| l.kind == LineKind::Para && l.containers.is_empty())
515    }
516
517    /// Whether the content carries no renderable content: the text is empty or
518    /// whitespace-only. An island slot ([`ISLAND_SLOT`], U+FFFC) is not
519    /// whitespace, so an island-bearing content is never blank. Body-disabled
520    /// validation and round-trip emit key on it.
521    pub fn is_blank(&self) -> bool {
522        self.text.trim().is_empty()
523    }
524
525    /// Number of `\n`-separated segments — the required `lines.len()`.
526    pub fn segment_count(&self) -> usize {
527        self.text.chars().filter(|c| *c == '\n').count() + 1
528    }
529
530    /// Normalize marks in place: drop zero-width formatting, union same-kind
531    /// formatting that is adjacent or overlapping, recursively key-sort island
532    /// props and unknown-mark attrs, then sort marks canonically. Idempotent —
533    /// the fixed point the canonical serialization commits to.
534    pub fn normalize(&mut self) {
535        // Line kinds whose contract names their content, against the content
536        // they now hold. A splice writes text, never kinds: typing into a table
537        // line leaves it `Island` over prose, joining a fence to an image line
538        // leaves it `Code` over a slot, and export reads the kind and not the
539        // text — so the un-repaired line projects its content away. Demote to
540        // `Para`, the kind that carries anything (an inline island is a slot in a
541        // `Para`), which is what re-importing the line's own markdown yields.
542        // The repair-side twin of the [`Invariant::LineKindMismatch`] check; the
543        // deliberate mis-tag is refused up front instead
544        // ([`ApplyError::LineKindMismatch`](crate::ops::ApplyError::LineKindMismatch)),
545        // since silently undoing an op the caller asked for is worse than an error.
546        // The same pass canonicalizes the open block vocabulary's opaque `attrs`:
547        // it is hash input like every other field, so two equal contents whose
548        // unknown lines were built key-reversed must not serialize to different
549        // bytes. A demoted line is `Para`, never `Unknown`, so the two are
550        // independent.
551        for (line, seg) in self.lines.iter_mut().zip(self.text.split('\n')) {
552            if line_kind_mismatch(&line.kind, seg).is_some() {
553                line.kind = LineKind::Para;
554            }
555            if let LineKind::Unknown { attrs, .. } = &mut line.kind {
556                canonicalize_keys(attrs);
557            }
558            for c in &mut line.containers {
559                if let Container::Unknown { attrs, .. } = c {
560                    canonicalize_keys(attrs);
561                }
562            }
563        }
564        // Islands: canonicalize props key order. A table island's cells carry
565        // inline `{text, marks}`; repair its shape (pad the header/rows/aligns to
566        // one column count, rewrite any cell `\n` to a space) and canonicalize
567        // each cell's marks (sort, union, drop zero-width) first so equal cells
568        // serialize to equal bytes and `validate` holds — the props are
569        // otherwise opaque here.
570        for island in &mut self.islands {
571            crate::island::normalize_island_structure(island);
572            canonicalize_keys(&mut island.props);
573        }
574        for mark in &mut self.marks {
575            if let MarkKind::Unknown { attrs, .. } = &mut mark.kind {
576                canonicalize_keys(attrs);
577            }
578        }
579        // A formatting mark's edges never sit on a line boundary: markdown can't
580        // bold a `\n`, so two producers that disagree only about whether the
581        // boundary is "inside" the mark must canonicalize to the same bounds.
582        // Trim leading/trailing `\n` (interior boundaries are kept — a mark may
583        // legitimately span lines). Zero-width results are dropped below.
584        // Skip the full-text char collection when nothing needs trimming.
585        if self.marks.iter().any(|m| m.kind.is_formatting()) {
586            let chars: Vec<char> = self.text.chars().collect();
587            for m in &mut self.marks {
588                if m.kind.is_formatting() {
589                    while m.start < m.end && chars.get(m.start) == Some(&'\n') {
590                        m.start += 1;
591                    }
592                    while m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
593                        m.end -= 1;
594                    }
595                }
596            }
597        }
598        self.marks = normalize_marks(std::mem::take(&mut self.marks));
599    }
600
601    /// Mark `type` names the projection reserves; an [`MarkKind::Unknown`] may
602    /// not reuse one (its serialization would parse back as the built-in,
603    /// silently dropping its attrs — non-injective). Checked by [`Content::validate`].
604    pub const RESERVED_MARK_TYPES: [&'static str; 7] = [
605        "strong",
606        "emph",
607        "underline",
608        "strike",
609        "code",
610        "link",
611        "anchor",
612    ];
613
614    /// Line `kind` names the projection reserves — the [`LineKind`] twin of
615    /// [`RESERVED_MARK_TYPES`](Self::RESERVED_MARK_TYPES), for the same
616    /// injectivity reason.
617    pub const RESERVED_LINE_KINDS: [&'static str; 5] =
618        ["para", "heading", "code", "island", "rule"];
619
620    /// Container names the projection reserves — the [`Container`] twin of
621    /// [`RESERVED_MARK_TYPES`](Self::RESERVED_MARK_TYPES).
622    pub const RESERVED_CONTAINERS: [&'static str; 2] = ["list_item", "quote"];
623
624    /// Check every invariant. `Ok(())` on a well-formed content. Import
625    /// guarantees this; a hand-built content should be run through it in tests.
626    pub fn validate(&self) -> Result<(), Invariant> {
627        let mut slots = 0usize;
628        for c in self.text.chars() {
629            if c == '\r' {
630                return Err(Invariant::CarriageReturn);
631            }
632            if is_bidi_char(c) {
633                return Err(Invariant::BidiControl(c));
634            }
635            if c == ISLAND_SLOT {
636                slots += 1;
637            }
638        }
639        if slots != self.islands.len() {
640            return Err(Invariant::IslandSlotMismatch {
641                slots,
642                islands: self.islands.len(),
643            });
644        }
645        let segments = self.segment_count();
646        if self.lines.len() != segments {
647            return Err(Invariant::LineCountMismatch {
648                lines: self.lines.len(),
649                segments,
650            });
651        }
652        if self.lines.first().is_some_and(|l| l.continues) {
653            return Err(Invariant::FirstLineContinues);
654        }
655        let len = self.len_usv();
656        let chars: Vec<char> = self.text.chars().collect();
657        // Prose anchor ids: unique, non-empty, caller-supplied opaque handles
658        // (`DOCUMENT_STORAGE.md` § Anchor-id identity). Uniqueness — the same
659        // invariant the island loop enforces below — is what `RemoveAnchor`
660        // presumes; scope is prose marks, cell anchors excluded by construction.
661        let mut seen_anchor_ids = std::collections::HashSet::new();
662        for m in &self.marks {
663            if m.start > m.end || m.end > len {
664                return Err(Invariant::MarkOutOfRange {
665                    start: m.start,
666                    end: m.end,
667                    len,
668                });
669            }
670            if m.start == m.end && m.kind.is_formatting() {
671                return Err(Invariant::ZeroWidthFormatting { at: m.start });
672            }
673            if m.kind.is_formatting() {
674                if chars.get(m.start) == Some(&'\n') {
675                    return Err(Invariant::MarkEdgeOnNewline { at: m.start });
676                }
677                if m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
678                    return Err(Invariant::MarkEdgeOnNewline { at: m.end - 1 });
679                }
680            }
681            match &m.kind {
682                MarkKind::Unknown { tag, .. } => {
683                    if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
684                        return Err(Invariant::ReservedUnknownTag(tag.clone()));
685                    }
686                }
687                MarkKind::Anchor { id } => {
688                    if id.is_empty() || !seen_anchor_ids.insert(id.as_str()) {
689                        return Err(Invariant::AnchorIdCollision { id: id.clone() });
690                    }
691                }
692                _ => {}
693            }
694        }
695        // One pass over the lines against their own text segment. `lines.len()`
696        // already equals the segment count, so the zip is total.
697        for (i, (line, seg)) in self.lines.iter().zip(self.text.split('\n')).enumerate() {
698            match &line.kind {
699                LineKind::Heading { level } if !(1..=6).contains(level) => {
700                    return Err(Invariant::BadHeadingLevel(*level));
701                }
702                // An unknown role may not reuse a built-in `kind` name: it would
703                // serialize as the built-in and parse back as one, dropping its
704                // attrs — the mark-side rule, one axis over.
705                LineKind::Unknown { tag, .. }
706                    if Self::RESERVED_LINE_KINDS.contains(&tag.as_str()) =>
707                {
708                    return Err(Invariant::ReservedUnknownLineKind(tag.clone()));
709                }
710                _ => {}
711            }
712            for c in &line.containers {
713                if let Container::Unknown { tag, .. } = c {
714                    if Self::RESERVED_CONTAINERS.contains(&tag.as_str()) {
715                        return Err(Invariant::ReservedUnknownContainer(tag.clone()));
716                    }
717                }
718            }
719            if let Some(mismatch) = line_kind_mismatch(&line.kind, seg) {
720                return Err(Invariant::LineKindMismatch { line: i, mismatch });
721            }
722            if line.containers.len() > crate::MAX_NESTING_DEPTH {
723                return Err(Invariant::NestingTooDeep {
724                    line: i,
725                    depth: line.containers.len(),
726                    max: crate::MAX_NESTING_DEPTH,
727                });
728            }
729        }
730        // Table-cell marks: the prose range/zero-width/reserved-tag rules again,
731        // but each mark is bounded by its own cell's text length (in USV). Cells
732        // hold no `\n`, so the edge-on-newline rule does not apply.
733        let mut seen_ids = std::collections::HashSet::with_capacity(self.islands.len());
734        for island in &self.islands {
735            // Ids are deterministic, session-stable identities (hash input), so
736            // two islands may not share one. Uniqueness — not `id == isl-{i}` —
737            // is the invariant: edits keep an island's id across renumbers.
738            if !seen_ids.insert(island.id.as_str()) {
739                return Err(Invariant::IslandIdCollision {
740                    id: island.id.clone(),
741                });
742            }
743            // Structural shape (table column/row/aligns consistency, `\n`-free
744            // cells) before the per-cell mark ranges — a ragged island is
745            // ill-formed regardless of its marks.
746            if let Some(e) = crate::island::island_shape_error(island) {
747                return Err(e);
748            }
749            for (text, marks) in crate::island::island_cell_marks(island) {
750                let clen = text.chars().count();
751                for m in &marks {
752                    if m.start > m.end || m.end > clen {
753                        return Err(Invariant::MarkOutOfRange {
754                            start: m.start,
755                            end: m.end,
756                            len: clen,
757                        });
758                    }
759                    if m.start == m.end && m.kind.is_formatting() {
760                        return Err(Invariant::ZeroWidthFormatting { at: m.start });
761                    }
762                    if let MarkKind::Unknown { tag, .. } = &m.kind {
763                        if Self::RESERVED_MARK_TYPES.contains(&tag.as_str()) {
764                            return Err(Invariant::ReservedUnknownTag(tag.clone()));
765                        }
766                    }
767                }
768            }
769        }
770        Ok(())
771    }
772}
773
774/// Apply the three Spike-A rules and the canonical sort to a flat mark list.
775///
776/// 1. Same-kind formatting marks union when adjacent *or* overlapping.
777/// 2. Different-kind marks overlap freely (never split into runs).
778/// 3. Identity (and unknown) marks never merge.
779///
780/// Zero-width formatting marks are dropped (no-ops); zero-width anchors survive.
781pub(crate) fn normalize_marks(marks: Vec<Mark>) -> Vec<Mark> {
782    use std::collections::BTreeMap;
783
784    // Partition: formatting marks group by (ord, attrs_key) for union; identity
785    // and unknown pass through untouched (but zero-width formatting is dropped).
786    let mut groups: BTreeMap<(u8, String), Vec<(Usv, Usv)>> = BTreeMap::new();
787    let mut kind_of: BTreeMap<(u8, String), MarkKind> = BTreeMap::new();
788    let mut passthrough: Vec<Mark> = Vec::new();
789
790    for m in marks {
791        if m.kind.is_formatting() {
792            if m.start >= m.end {
793                continue; // drop zero-width / inverted formatting
794            }
795            let key = (m.kind.ord(), m.kind.attrs_key());
796            kind_of.entry(key.clone()).or_insert_with(|| m.kind.clone());
797            groups.entry(key).or_default().push((m.start, m.end));
798        } else {
799            passthrough.push(m);
800        }
801    }
802
803    let mut out: Vec<Mark> = Vec::new();
804    for (key, mut ranges) in groups {
805        ranges.sort_unstable();
806        let kind = kind_of.remove(&key).expect("kind recorded with group");
807        let mut cur = ranges[0];
808        for &(s, e) in &ranges[1..] {
809            if s <= cur.1 {
810                // adjacent (s == cur.1) or overlapping — union
811                cur.1 = cur.1.max(e);
812            } else {
813                out.push(Mark {
814                    start: cur.0,
815                    end: cur.1,
816                    kind: kind.clone(),
817                });
818                cur = (s, e);
819            }
820        }
821        out.push(Mark {
822            start: cur.0,
823            end: cur.1,
824            kind,
825        });
826    }
827    out.extend(passthrough);
828
829    // Canonical sort: (start, end, kind-ord, attrs). Key cached per mark so
830    // `attrs_key`'s allocation runs once each, not once per comparison.
831    out.sort_by_cached_key(|m| (m.start, m.end, m.kind.ord(), m.kind.attrs_key()));
832    // Drop byte-identical duplicates. Identity/unknown handles never *merge*
833    // (Spike-A rule 3), but two marks equal in range, kind, and attrs are the
834    // same handle recorded twice — redundant bytes, not two handles. The sort
835    // above makes any such pair adjacent, so `dedup` (structural `PartialEq`,
836    // order-independent for `Unknown` attrs under `preserve_order`) removes it.
837    out.dedup();
838    out
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    fn f(start: Usv, end: Usv, kind: MarkKind) -> Mark {
846        Mark { start, end, kind }
847    }
848
849    #[test]
850    fn is_blank_tracks_whitespace_and_islands() {
851        assert!(Content::empty().is_blank());
852        let mut ws = Content::empty();
853        ws.text = "  \n\t ".to_string();
854        ws.lines = vec![
855            Line {
856                kind: LineKind::Para,
857                containers: Vec::new(),
858                continues: false,
859            },
860            Line {
861                kind: LineKind::Para,
862                containers: Vec::new(),
863                continues: false,
864            },
865        ];
866        assert!(ws.is_blank(), "whitespace-only text is blank");
867
868        let mut has_text = Content::empty();
869        has_text.text = "x".to_string();
870        assert!(!has_text.is_blank());
871
872        // An island slot is not whitespace, so an island-bearing content is
873        // never blank even with no other text.
874        let mut island_only = Content::empty();
875        island_only.text = ISLAND_SLOT.to_string();
876        assert!(!island_only.is_blank());
877    }
878
879    /// A single-line content over `text` tagged `kind` — the shape a `SetKind`
880    /// or a splice can leave behind.
881    fn tagged(text: &str, kind: LineKind) -> Content {
882        Content {
883            text: text.to_string(),
884            lines: vec![Line {
885                kind,
886                containers: Vec::new(),
887                continues: false,
888            }],
889            marks: Vec::new(),
890            islands: Vec::new(),
891        }
892    }
893
894    /// Issue #1050: a line kind that contradicts the line's text is refused.
895    /// Export trusts the kind and never re-reads the segment, so `Island` over
896    /// prose projects to the island alone and `Rule` over prose to `---` — the
897    /// text silently gone.
898    #[test]
899    fn line_kind_must_agree_with_line_text() {
900        assert_eq!(
901            tagged("hello world", LineKind::Island).validate(),
902            Err(Invariant::LineKindMismatch {
903                line: 0,
904                mismatch: LineKindMismatch::IslandNotOneSlot
905            })
906        );
907        assert_eq!(
908            tagged("", LineKind::Island).validate(),
909            Err(Invariant::LineKindMismatch {
910                line: 0,
911                mismatch: LineKindMismatch::IslandNotOneSlot
912            })
913        );
914        assert_eq!(
915            tagged("important text", LineKind::Rule).validate(),
916            Err(Invariant::LineKindMismatch {
917                line: 0,
918                mismatch: LineKindMismatch::RuleNotEmpty
919            })
920        );
921        // `Para`/`Heading` carry slots — an inline image is a slot in prose —
922        // so only a fence, whose text is emitted verbatim, refuses one.
923        let mut code = tagged(&format!("a{ISLAND_SLOT}b"), LineKind::Code { lang: None });
924        code.islands = vec![Island {
925            id: "isl-0".into(),
926            island_type: "image".into(),
927            props: serde_json::json!({"alt": "x", "url": "y.png"}),
928            loss: Loss::Lossless,
929        }];
930        assert_eq!(
931            code.validate(),
932            Err(Invariant::LineKindMismatch {
933                line: 0,
934                mismatch: LineKindMismatch::CodeHasSlot
935            })
936        );
937        let mut para = code.clone();
938        para.lines[0].kind = LineKind::Para;
939        assert_eq!(para.validate(), Ok(()));
940        let mut heading = code.clone();
941        heading.lines[0].kind = LineKind::Heading { level: 1 };
942        assert_eq!(heading.validate(), Ok(()));
943        // The kinds that name their content, holding it.
944        assert_eq!(tagged("", LineKind::Rule).validate(), Ok(()));
945    }
946
947    /// Issue #1050: a splice writes text, never kinds, so it can strand an
948    /// `Island` line over prose. `normalize` demotes the stranded kind to `Para`
949    /// rather than let export drop the text — the repair-side twin of the
950    /// invariant, and what keeps every op path's terminal normalize total.
951    #[test]
952    fn normalize_demotes_a_stranded_line_kind() {
953        let mut rt = tagged("typed into a table line", LineKind::Island);
954        rt.normalize();
955        assert_eq!(rt.lines[0].kind, LineKind::Para);
956        assert_eq!(rt.validate(), Ok(()));
957        let mut rt = tagged("text on a rule line", LineKind::Rule);
958        rt.normalize();
959        assert_eq!(rt.lines[0].kind, LineKind::Para);
960        // A well-formed island line is left alone.
961        let mut rt = tagged(&ISLAND_SLOT.to_string(), LineKind::Island);
962        rt.islands = vec![Island {
963            id: "isl-0".into(),
964            island_type: "image".into(),
965            props: serde_json::json!({"alt": "x", "url": "y.png"}),
966            loss: Loss::Lossless,
967        }];
968        rt.normalize();
969        assert_eq!(rt.lines[0].kind, LineKind::Island);
970        assert_eq!(rt.validate(), Ok(()));
971    }
972
973    /// Issue #1051: container nesting is capped at the same depth import
974    /// enforces, so a content that never went through import cannot reach the
975    /// emitters — which recurse one frame per container — with an unbounded path.
976    #[test]
977    fn container_nesting_is_capped() {
978        let mut rt = tagged("hi", LineKind::Para);
979        rt.lines[0].containers = vec![Container::Quote; crate::MAX_NESTING_DEPTH];
980        assert_eq!(rt.validate(), Ok(()));
981        rt.lines[0].containers.push(Container::Quote);
982        assert_eq!(
983            rt.validate(),
984            Err(Invariant::NestingTooDeep {
985                line: 0,
986                depth: crate::MAX_NESTING_DEPTH + 1,
987                max: crate::MAX_NESTING_DEPTH,
988            })
989        );
990    }
991
992    #[test]
993    fn same_kind_adjacent_unions() {
994        // [0,3) strong + [3,6) strong -> [0,6) strong (rule 1, adjacency).
995        let got = normalize_marks(vec![f(3, 6, MarkKind::Strong), f(0, 3, MarkKind::Strong)]);
996        assert_eq!(got, vec![f(0, 6, MarkKind::Strong)]);
997    }
998
999    #[test]
1000    fn same_kind_overlapping_unions() {
1001        let got = normalize_marks(vec![f(0, 4, MarkKind::Emph), f(2, 7, MarkKind::Emph)]);
1002        assert_eq!(got, vec![f(0, 7, MarkKind::Emph)]);
1003    }
1004
1005    #[test]
1006    fn different_kinds_overlap_freely() {
1007        // Strong and emph over overlapping ranges stay two marks (rule 2).
1008        let got = normalize_marks(vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]);
1009        assert_eq!(
1010            got,
1011            vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]
1012        );
1013    }
1014
1015    #[test]
1016    fn links_union_only_at_same_url() {
1017        let a = MarkKind::Link { url: "a".into() };
1018        let b = MarkKind::Link { url: "b".into() };
1019        // Same url adjacent -> union; different url -> distinct.
1020        let got = normalize_marks(vec![
1021            f(0, 2, a.clone()),
1022            f(2, 4, a.clone()),
1023            f(4, 6, b.clone()),
1024        ]);
1025        assert_eq!(got, vec![f(0, 4, a), f(4, 6, b)]);
1026    }
1027
1028    #[test]
1029    fn identity_never_merges() {
1030        // Two anchors over the same range are two distinct things (rule 3).
1031        let a = MarkKind::Anchor { id: "c1".into() };
1032        let b = MarkKind::Anchor { id: "c2".into() };
1033        let got = normalize_marks(vec![f(3, 3, a.clone()), f(3, 3, b.clone())]);
1034        assert_eq!(got.len(), 2);
1035        assert!(got.contains(&f(3, 3, a)));
1036        assert!(got.contains(&f(3, 3, b)));
1037    }
1038
1039    #[test]
1040    fn zero_width_formatting_dropped_zero_width_anchor_kept() {
1041        let got = normalize_marks(vec![
1042            f(2, 2, MarkKind::Strong),
1043            f(2, 2, MarkKind::Anchor { id: "x".into() }),
1044        ]);
1045        assert_eq!(got, vec![f(2, 2, MarkKind::Anchor { id: "x".into() })]);
1046    }
1047
1048    #[test]
1049    fn empty_is_valid() {
1050        assert_eq!(Content::empty().validate(), Ok(()));
1051    }
1052
1053    #[test]
1054    fn is_inline_accepts_empty_and_single_para() {
1055        assert!(Content::empty().is_inline());
1056        assert!(crate::import::from_markdown("just one line")
1057            .unwrap()
1058            .is_inline());
1059        assert!(crate::import::from_markdown("a *bold* run")
1060            .unwrap()
1061            .is_inline());
1062    }
1063
1064    #[test]
1065    fn is_inline_rejects_blocks_containers_and_islands() {
1066        // Two paragraphs → two Para lines.
1067        assert!(!crate::import::from_markdown("one\n\ntwo")
1068            .unwrap()
1069            .is_inline());
1070        // A heading is a non-Para line kind.
1071        assert!(!crate::import::from_markdown("# heading")
1072            .unwrap()
1073            .is_inline());
1074        // A list item sits in a container.
1075        assert!(!crate::import::from_markdown("- item").unwrap().is_inline());
1076    }
1077
1078    #[test]
1079    fn validate_catches_slot_mismatch() {
1080        let mut rt = Content::empty();
1081        rt.text = "\u{FFFC}".into();
1082        rt.lines = vec![Line {
1083            kind: LineKind::Island,
1084            containers: vec![],
1085            continues: false,
1086        }];
1087        assert_eq!(
1088            rt.validate(),
1089            Err(Invariant::IslandSlotMismatch {
1090                slots: 1,
1091                islands: 0
1092            })
1093        );
1094    }
1095
1096    #[test]
1097    fn validate_catches_line_count() {
1098        let mut rt = Content::empty();
1099        rt.text = "a\nb".into(); // 2 segments, but 1 line
1100        assert_eq!(
1101            rt.validate(),
1102            Err(Invariant::LineCountMismatch {
1103                lines: 1,
1104                segments: 2
1105            })
1106        );
1107    }
1108
1109    #[test]
1110    fn normalize_is_idempotent() {
1111        let mut rt = Content::empty();
1112        rt.text = "hello world".into();
1113        rt.marks = vec![
1114            f(6, 11, MarkKind::Strong),
1115            f(0, 5, MarkKind::Strong),
1116            f(0, 5, MarkKind::Emph),
1117        ];
1118        rt.normalize();
1119        let once = rt.marks.clone();
1120        rt.normalize();
1121        assert_eq!(rt.marks, once);
1122        assert_eq!(rt.validate(), Ok(()));
1123    }
1124
1125    /// A table cell built with un-normalized marks (reversed order, an adjacent
1126    /// same-kind pair, a zero-width formatting mark) canonicalizes to the same
1127    /// marks whatever the input order — the live-model determinism invariant.
1128    #[test]
1129    fn table_cell_marks_normalize_and_are_idempotent() {
1130        fn table(cell_marks: serde_json::Value) -> Content {
1131            let mut rt = Content::empty();
1132            rt.text = ISLAND_SLOT.to_string();
1133            rt.lines = vec![Line {
1134                kind: LineKind::Island,
1135                containers: vec![],
1136                continues: false,
1137            }];
1138            rt.islands = vec![Island {
1139                id: "i".into(),
1140                island_type: "table".into(),
1141                props: serde_json::json!({
1142                    "aligns": ["none"],
1143                    "header": [{"text": "abcd", "marks": cell_marks}],
1144                    "rows": [],
1145                }),
1146                loss: Loss::Lossless,
1147            }];
1148            rt
1149        }
1150        // Reversed order + adjacent same-kind pair (0..2)+(2..4) → unioned 0..4;
1151        // a zero-width strong at 1 → dropped.
1152        let mut a = table(serde_json::json!([
1153            {"start": 2, "end": 4, "type": "strong"},
1154            {"start": 1, "end": 1, "type": "strong"},
1155            {"start": 0, "end": 2, "type": "strong"}
1156        ]));
1157        a.normalize();
1158        assert_eq!(a.validate(), Ok(()));
1159        let cell = &a.islands[0].props["header"][0];
1160        assert_eq!(cell["marks"].as_array().unwrap().len(), 1);
1161        assert_eq!(cell["marks"][0]["start"], 0);
1162        assert_eq!(cell["marks"][0]["end"], 4);
1163        // Same content, different input order → identical canonical bytes.
1164        let mut b = table(serde_json::json!([
1165            {"start": 0, "end": 2, "type": "strong"},
1166            {"start": 2, "end": 4, "type": "strong"}
1167        ]));
1168        b.normalize();
1169        assert_eq!(a.to_canonical_json(), b.to_canonical_json());
1170        // Idempotent.
1171        let once = a.to_canonical_json();
1172        a.normalize();
1173        assert_eq!(a.to_canonical_json(), once);
1174    }
1175
1176    /// `validate` bounds a cell mark by its own cell's text length (in USV).
1177    #[test]
1178    fn validate_catches_cell_mark_out_of_range() {
1179        let mut rt = Content::empty();
1180        rt.text = ISLAND_SLOT.to_string();
1181        rt.lines = vec![Line {
1182            kind: LineKind::Island,
1183            containers: vec![],
1184            continues: false,
1185        }];
1186        rt.islands = vec![Island {
1187            id: "i".into(),
1188            island_type: "table".into(),
1189            props: serde_json::json!({
1190                "aligns": ["none"],
1191                // "ab" is 2 USV; a mark ending at 5 runs past the cell.
1192                "header": [{"text": "ab", "marks": [{"start": 0, "end": 5, "type": "strong"}]}],
1193                "rows": [],
1194            }),
1195            loss: Loss::Lossless,
1196        }];
1197        assert_eq!(
1198            rt.validate(),
1199            Err(Invariant::MarkOutOfRange {
1200                start: 0,
1201                end: 5,
1202                len: 2
1203            })
1204        );
1205    }
1206
1207    /// A `Content` holding a single table island with the given props — the
1208    /// shared fixture for the table-shape invariant tests.
1209    fn table_rt(props: serde_json::Value) -> Content {
1210        let mut rt = Content::empty();
1211        rt.text = ISLAND_SLOT.to_string();
1212        rt.lines = vec![Line {
1213            kind: LineKind::Island,
1214            containers: vec![],
1215            continues: false,
1216        }];
1217        rt.islands = vec![Island {
1218            id: "i".into(),
1219            island_type: "table".into(),
1220            props,
1221            loss: Loss::Lossless,
1222        }];
1223        rt
1224    }
1225
1226    fn cell(t: &str) -> serde_json::Value {
1227        serde_json::json!({ "text": t, "marks": [] })
1228    }
1229
1230    /// `validate` rejects a ragged body row, an `aligns`/column mismatch, and a
1231    /// cell carrying a `\n` — the three table-shape invariants.
1232    #[test]
1233    fn validate_catches_table_shape() {
1234        // Ragged row: header has 2 columns, the row has 3.
1235        let rt = table_rt(serde_json::json!({
1236            "aligns": ["none", "none"],
1237            "header": [cell("a"), cell("b")],
1238            "rows": [[cell("1"), cell("2"), cell("3")]],
1239        }));
1240        assert_eq!(
1241            rt.validate(),
1242            Err(Invariant::TableRaggedRow {
1243                row: 0,
1244                width: 3,
1245                cols: 2
1246            })
1247        );
1248
1249        // aligns length differs from the column count.
1250        let rt = table_rt(serde_json::json!({
1251            "aligns": ["none"],
1252            "header": [cell("a"), cell("b")],
1253            "rows": [],
1254        }));
1255        assert_eq!(
1256            rt.validate(),
1257            Err(Invariant::TableAlignsMismatch { aligns: 1, cols: 2 })
1258        );
1259
1260        // A `\n` in a cell (flat header-then-rows index 1 = the second header cell).
1261        let rt = table_rt(serde_json::json!({
1262            "aligns": ["none", "none"],
1263            "header": [cell("a"), cell("b\nc")],
1264            "rows": [],
1265        }));
1266        assert_eq!(rt.validate(), Err(Invariant::TableCellNewline { cell: 1 }));
1267    }
1268
1269    /// `normalize` repairs every table-shape violation — pads the header and
1270    /// short rows to the widest column count, syncs `aligns`, and rewrites a
1271    /// cell `\n` to a space — so the result validates and is idempotent. This is
1272    /// also the one-column-count unification: the widest row (3) drives the
1273    /// header width, so the markdown (header-derived) and Typst (widest-row)
1274    /// projections agree.
1275    #[test]
1276    fn normalize_repairs_table_shape() {
1277        let mut rt = table_rt(serde_json::json!({
1278            "aligns": ["none"],
1279            "header": [cell("h")],
1280            "rows": [
1281                [cell("a"), cell("b"), cell("c")],
1282                [cell("d\ne")],
1283            ],
1284        }));
1285        rt.normalize();
1286        assert_eq!(rt.validate(), Ok(()));
1287
1288        let props = &rt.islands[0].props;
1289        assert_eq!(props["header"].as_array().unwrap().len(), 3);
1290        assert_eq!(props["aligns"].as_array().unwrap().len(), 3);
1291        for row in props["rows"].as_array().unwrap() {
1292            assert_eq!(row.as_array().unwrap().len(), 3);
1293        }
1294        // Padded aligns default to "none"; the padded cells are empty.
1295        assert_eq!(props["aligns"][2], serde_json::json!("none"));
1296        assert_eq!(props["header"][1]["text"], serde_json::json!(""));
1297        // The `\n` in "d\ne" became a space, preserving char count.
1298        assert_eq!(props["rows"][1][0]["text"], serde_json::json!("d e"));
1299
1300        // Idempotent on canonical bytes.
1301        let once = rt.to_canonical_json();
1302        rt.normalize();
1303        assert_eq!(rt.to_canonical_json(), once);
1304    }
1305
1306    /// An empty table (no header, no rows) is trivially well-formed: every width
1307    /// is zero, so no shape invariant fires and `normalize` leaves it alone.
1308    #[test]
1309    fn empty_table_is_valid() {
1310        let mut rt = table_rt(serde_json::json!({
1311            "aligns": [],
1312            "header": [],
1313            "rows": [],
1314        }));
1315        assert_eq!(rt.validate(), Ok(()));
1316        rt.normalize();
1317        assert_eq!(rt.validate(), Ok(()));
1318    }
1319
1320    /// A non-array `header` carries no cells: `validate` rejects it and
1321    /// `normalize` repairs it to an empty array (a zero-column table that then
1322    /// validates). Issue #904.
1323    #[test]
1324    fn non_array_table_header_is_rejected_then_repaired() {
1325        let mut rt = table_rt(serde_json::json!({
1326            "header": "oops",
1327            "aligns": [],
1328            "rows": [],
1329        }));
1330        assert_eq!(rt.validate(), Err(Invariant::TableHeaderNotArray));
1331        rt.normalize();
1332        assert_eq!(rt.validate(), Ok(()));
1333        assert_eq!(rt.islands[0].props["header"], serde_json::json!([]));
1334    }
1335
1336    /// Two islands sharing an `id` violate the minted-identity invariant.
1337    /// Import mints ids by index so never collides; a hand-built content can.
1338    /// Issue #903.
1339    #[test]
1340    fn duplicate_island_id_is_rejected() {
1341        let mut rt = Content::empty();
1342        rt.text = format!("{ISLAND_SLOT}\n{ISLAND_SLOT}");
1343        rt.lines = vec![
1344            Line {
1345                kind: LineKind::Island,
1346                containers: vec![],
1347                continues: false,
1348            },
1349            Line {
1350                kind: LineKind::Island,
1351                containers: vec![],
1352                continues: false,
1353            },
1354        ];
1355        let table = |id: &str| Island {
1356            id: id.into(),
1357            island_type: "table".into(),
1358            props: serde_json::json!({ "header": [cell("h")], "aligns": ["none"], "rows": [] }),
1359            loss: Loss::Lossless,
1360        };
1361        rt.islands = vec![table("dup"), table("dup")];
1362        assert_eq!(
1363            rt.validate(),
1364            Err(Invariant::IslandIdCollision { id: "dup".into() })
1365        );
1366        // Distinct ids validate.
1367        rt.islands = vec![table("a"), table("b")];
1368        assert_eq!(rt.validate(), Ok(()));
1369    }
1370
1371    /// Two prose anchors sharing an `id` at different ranges violate the
1372    /// anchor-id uniqueness invariant, as does the empty id. (Byte-identical
1373    /// anchors `normalize` already dedupes; this is the surviving collision.)
1374    /// Issue #1039.
1375    #[test]
1376    fn duplicate_or_empty_anchor_id_is_rejected() {
1377        let mut rt = Content::empty();
1378        rt.text = "abcd".into();
1379        let anchor = |start, end, id: &str| Mark {
1380            start,
1381            end,
1382            kind: MarkKind::Anchor { id: id.into() },
1383        };
1384        // Same id at two ranges — `RemoveAnchor` can't disambiguate them.
1385        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "x")];
1386        assert_eq!(
1387            rt.validate(),
1388            Err(Invariant::AnchorIdCollision { id: "x".into() })
1389        );
1390        // Distinct ids over distinct ranges validate.
1391        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "y")];
1392        assert_eq!(rt.validate(), Ok(()));
1393        // The empty id is a degenerate handle.
1394        rt.marks = vec![anchor(0, 2, "")];
1395        assert_eq!(
1396            rt.validate(),
1397            Err(Invariant::AnchorIdCollision { id: String::new() })
1398        );
1399    }
1400
1401    /// `normalize` drops a byte-identical duplicate identity mark (same range,
1402    /// same id) — the same handle recorded twice is redundant, not two handles.
1403    /// Distinct-id anchors over the same range are kept. Issue #906.
1404    #[test]
1405    fn normalize_dedupes_identical_identity_marks() {
1406        let mut rt = Content::empty();
1407        rt.text = "abcd".into();
1408        let anchor = |id: &str| Mark {
1409            start: 0,
1410            end: 4,
1411            kind: MarkKind::Anchor { id: id.into() },
1412        };
1413        rt.marks = vec![anchor("x"), anchor("x")];
1414        rt.normalize();
1415        assert_eq!(rt.marks, vec![anchor("x")]);
1416        // Different ids over the same range are distinct handles — both survive.
1417        rt.marks = vec![anchor("x"), anchor("y")];
1418        rt.normalize();
1419        assert_eq!(rt.marks.len(), 2);
1420    }
1421}