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//! Editor-specific policy (edge-expand, adjacent-merge-at-insertion) is *not*
6//! encoded: the model stores only the resulting range, so the stored form is
7//! identical whatever the editor did.
8
9use crate::island::IslandType;
10use crate::normalize::{is_bidi_char, is_line_separator};
11use serde_json::Value as JsonValue;
12use std::borrow::Cow;
13
14/// A position in a [`Content`], counted in Unicode scalar values (USV): never
15/// bytes, never UTF-16 units. One astral char is 1 USV / 4 UTF-8 bytes / 2
16/// UTF-16 units. [`crate::usv`] converts one to the UTF-8 byte offset Rust
17/// slicing needs.
18pub type Usv = usize;
19
20/// U+FFFC OBJECT REPLACEMENT CHARACTER: the single-USV slot an island occupies
21/// in the content. One slot per island; every slot has a backing island. A stray
22/// slot (or a slot with no island) is an invariant violation, which
23/// [`Content::validate`] reports and the mint cannot repair — so a codec taking
24/// text it did not write drops one on the way in, beside the `\r`, bidi controls
25/// and line separators the same input loses. Establishing the invariant is the
26/// codec's job, not a hole in it.
27pub const ISLAND_SLOT: char = '\u{FFFC}';
28
29/// One content field as a content: the text plus the structure that rides on it.
30///
31/// The mint ([`Content::into_normalized`]) establishes the canonical form:
32/// marks sorted and unioned, container paths renumbered, a line's kind agreeing
33/// with its text, a block island's slot alone on its line, table props on one
34/// column count. [`Content::validate`] reports what the mint cannot repair: the
35/// text holds no `\r`, no bidi controls, and no line separator (every character
36/// Typst reads as a newline but `\n`); the count of [`ISLAND_SLOT`] equals
37/// `islands.len()`; `lines.len()` equals the number of `\n`-separated segments.
38#[derive(Debug, Clone, PartialEq)]
39pub struct Content {
40    /// The content. `\n` is a line boundary; [`ISLAND_SLOT`] is an island slot.
41    pub text: String,
42    /// One entry per `\n`-separated segment of `text`, in order. The line tree
43    /// is *derived* from this flat list plus each line's `containers` path,
44    /// never stored, so a split/join is a single-char edit with no paragraph
45    /// identity to reconcile.
46    pub lines: Vec<Line>,
47    /// Marks over char ranges, kept normalized: sorted by
48    /// `(start, end, type, attrs)`, same-kind formatting marks unioned.
49    pub marks: Vec<Mark>,
50    /// One entry per [`ISLAND_SLOT`], in slot order (ascending char position).
51    pub islands: Vec<Island>,
52}
53
54/// A line's attributes: its block role plus the container path it sits in.
55#[derive(Debug, Clone, PartialEq)]
56pub struct Line {
57    pub kind: LineKind,
58    /// Ancestor containers, outermost first. A multi-paragraph list item is two
59    /// `Para` lines sharing one `[ListItem]` path; a paragraph in a quote in a
60    /// list item is `[ListItem, Quote]`.
61    pub containers: Vec<Container>,
62    /// Whether this line continues the previous line's *block* across a hard
63    /// line break rather than starting a new block. `false` = a new block
64    /// (paragraph spacing on either side); `true` = a within-block line break (a
65    /// markdown hard break; consecutive lines of one code fence). The first line
66    /// is always `false`, as is one whose container path differs from the line
67    /// above or whose block above renders a single line
68    /// ([`LineKind::takes_continuations`]).
69    pub continues: bool,
70}
71
72impl Line {
73    /// A line of `kind` at the top level: no containers, starting a new block,
74    /// which is also what the wire reads off the absent keys.
75    pub fn new(kind: LineKind) -> Self {
76        Line {
77            kind,
78            containers: Vec::new(),
79            continues: false,
80        }
81    }
82
83    /// Set the ancestor path, outermost first.
84    pub fn with_containers(mut self, containers: Vec<Container>) -> Self {
85        self.containers = containers;
86        self
87    }
88
89    /// Set [`continues`](Self::continues): `true` makes this line a within-block
90    /// break off the previous one rather than a new block.
91    pub fn with_continues(mut self, continues: bool) -> Self {
92        self.continues = continues;
93        self
94    }
95}
96
97/// The block role of a line. The tree between lines is inferred: two adjacent
98/// lines with equal `kind`+`containers` are two blocks of that role (e.g. two
99/// paragraphs), never one.
100///
101/// **Closed**, on the same terms as [`MarkKind`]: a `kind` outside this set is
102/// [`ParseError::UnknownName`](crate::serial::ParseError::UnknownName) at every
103/// decoder, so adding a role is a storage-version event.
104#[derive(Debug, Clone, PartialEq)]
105pub enum LineKind {
106    Para,
107    /// ATX/Setext heading, level 1..=6.
108    Heading {
109        level: u8,
110    },
111    /// A line of a code block. `lang` is the (sanitized) info string, shared by
112    /// every line of the same block.
113    Code {
114        lang: Option<String>,
115    },
116    /// A block-level island: the line's sole content is one [`ISLAND_SLOT`]
117    /// backing an island whose markdown *is* a block
118    /// ([`IslandType::block_only`](crate::island::IslandType::block_only)).
119    /// An image is inline markup, so a line holding one alone is
120    /// [`Para`](LineKind::Para) — the kind re-importing `![alt](url)` yields,
121    /// and the kind [`Content::normalize`] writes there.
122    Island,
123    /// A thematic break (`---`/`***`/`___`). The line carries no text.
124    Rule,
125}
126
127impl LineKind {
128    /// Whether a block of this kind renders the lines that [`Line::continues`]
129    /// joins to its first. A paragraph spans its hard-break run and a code
130    /// block its fence's interior; a heading, an island and a rule are one
131    /// line, and both emitters render that line alone, so a continuation there
132    /// is text the projection never reaches.
133    pub fn takes_continuations(&self) -> bool {
134        matches!(self, LineKind::Para | LineKind::Code { .. })
135    }
136
137    /// The wire `kind` name.
138    pub fn tag(&self) -> &'static str {
139        match self {
140            LineKind::Para => "para",
141            LineKind::Heading { .. } => "heading",
142            LineKind::Code { .. } => "code",
143            LineKind::Island => "island",
144            LineKind::Rule => "rule",
145        }
146    }
147
148    /// The payload bag, one spelling for every member of the vocabulary.
149    pub fn attrs(&self) -> Cow<'_, JsonValue> {
150        match self {
151            LineKind::Para | LineKind::Island | LineKind::Rule => Cow::Owned(JsonValue::Null),
152            LineKind::Heading { level } => Cow::Owned(bag([("level", (*level).into())])),
153            LineKind::Code { lang } => Cow::Owned(match lang {
154                Some(l) => bag([("lang", l.as_str().into())]),
155                None => JsonValue::Null,
156            }),
157        }
158    }
159}
160
161/// A payload bag from its entries, which must be listed in ascending key order:
162/// [`Content::normalize`] canonicalizes an opaque bag, and a minted one is
163/// canonical by construction.
164fn bag<const N: usize>(entries: [(&str, JsonValue); N]) -> JsonValue {
165    debug_assert!(entries.windows(2).all(|w| w[0].0 < w[1].0));
166    let mut m = serde_json::Map::with_capacity(N);
167    for (k, v) in entries {
168        m.insert(k.to_string(), v);
169    }
170    JsonValue::Object(m)
171}
172
173/// Whether a payload bag holds nothing: the two spellings of "no payload" a
174/// decode can produce (an absent key reads as `Null`, an empty object is one).
175/// [`Content::normalize`] collapses them to `Null`, so a bag's presence on the
176/// wire stays a pure function of the value.
177pub(crate) fn is_empty_bag(v: &JsonValue) -> bool {
178    match v {
179        JsonValue::Null => true,
180        JsonValue::Object(m) => m.is_empty(),
181        _ => false,
182    }
183}
184
185/// A container a line nests inside. The ancestor path is a `Vec<Container>`.
186///
187/// **Closed**, on [`LineKind`]'s terms: a `container` outside this set is
188/// [`ParseError::UnknownName`](crate::serial::ParseError::UnknownName).
189#[derive(Debug, Clone, PartialEq)]
190pub enum Container {
191    /// A list item. `ordered` distinguishes `1.` from `-`; `start` is the list's
192    /// first number (1 by default); `ordinal` is this item's 0-based index in
193    /// its list; `instance` tells this list from an adjacent one of the same
194    /// shape (see [`Container::instance`]).
195    ///
196    /// Two *adjacent* lines belong to the same item iff their whole container
197    /// path is equal. Identity is path **plus contiguity**: two sibling inner
198    /// lists under one outer item can produce equal first-item paths,
199    /// distinguished only by the non-adjacency of their runs.
200    ListItem {
201        ordered: bool,
202        start: u64,
203        ordinal: u64,
204        instance: u64,
205    },
206    /// A block quote. Adjacent lines sharing one `Quote` are one
207    /// multi-paragraph quote; two adjacent quotes differ in `instance`.
208    Quote { instance: u64 },
209}
210
211impl Container {
212    /// Which instance of its shape this container is, among a run of adjacent
213    /// siblings that would otherwise be indistinguishable.
214    ///
215    /// Container identity is path plus contiguity, so two adjacent runs of
216    /// equal shape read as one: `[Quote], [Quote]` is one quote, and two
217    /// one-item lists are one item spanning two paragraphs. `instance` is the
218    /// one field that exists to break that tie, and it is the whole reason the
219    /// encoding is complete rather than a quotient.
220    ///
221    /// A producer writes it against [`same_run`](Self::same_run): two adjacent
222    /// runs of one shape need distinct values, whatever they are, or they
223    /// arrive as one container. [`Content::normalize`] collapses those to the
224    /// canonical pair — **0, flipping to 1 only where the projection would
225    /// otherwise [weld](Self::same_weld) the two** — so a document needing no
226    /// discriminator carries none and one that needs it alternates
227    /// `0, 1, 0, 1`. Non-adjacent runs never collide, so two values suffice.
228    pub fn instance(&self) -> u64 {
229        match self {
230            Container::ListItem { instance, .. } | Container::Quote { instance } => *instance,
231        }
232    }
233
234    /// The wire `container` name.
235    pub fn tag(&self) -> &'static str {
236        match self {
237            Container::ListItem { .. } => "list_item",
238            Container::Quote { .. } => "quote",
239        }
240    }
241
242    /// The payload bag, one spelling for every member of the vocabulary.
243    /// `instance` is not in it: it is an envelope key, carried on every arm.
244    pub fn attrs(&self) -> Cow<'_, JsonValue> {
245        match self {
246            Container::ListItem {
247                ordered,
248                start,
249                ordinal,
250                ..
251            } => Cow::Owned(bag([
252                ("ordered", (*ordered).into()),
253                ("ordinal", (*ordinal).into()),
254                ("start", (*start).into()),
255            ])),
256            Container::Quote { .. } => Cow::Owned(JsonValue::Null),
257        }
258    }
259
260    fn set_instance(&mut self, n: u64) {
261        match self {
262            Container::ListItem { instance, .. } | Container::Quote { instance } => *instance = n,
263        }
264    }
265
266    /// Whether these two are the same container shape, `ordinal` and `instance`
267    /// aside — `start` counts, so a list starting at 1 and one starting at 3
268    /// are two shapes.
269    ///
270    /// The **identity** rule, read and written alike: two adjacent lines sit in
271    /// one container instance iff this holds *and* their
272    /// [`instance`](Self::instance)s are equal. [`crate::traverse::runs`]
273    /// applies it, and a producer separates its runs against it. Whether the
274    /// *projection* can then tell two runs apart is
275    /// [`same_weld`](Self::same_weld).
276    pub fn same_run(&self, other: &Container) -> bool {
277        match (self, other) {
278            (
279                Container::ListItem {
280                    ordered: a, start: b, ..
281                },
282                Container::ListItem {
283                    ordered: c, start: d, ..
284                },
285            ) => a == c && b == d,
286            (Container::Quote { .. }, Container::Quote { .. }) => true,
287            _ => false,
288        }
289    }
290
291    /// Whether the Markdown projection would read two adjacent runs of these
292    /// shapes as one — that is, whether the canonical form must spend an
293    /// [`instance`](Self::instance) to keep them apart.
294    /// [`Content::normalize`] mints against this;
295    /// [`same_run`](Self::same_run) is what a producer writes against.
296    ///
297    /// Coarser than `same_run` for lists, because `start` is invisible in
298    /// Markdown: CommonMark reads only a list's *first* number, so `1. a`
299    /// beside `3. b` re-imports as one list of two items and the second list's
300    /// `start` is lost. Comparing `ordered` alone mints the discriminator
301    /// there too, and the marker alternation carries it.
302    pub fn same_weld(&self, other: &Container) -> bool {
303        match (self, other) {
304            (Container::ListItem { ordered: a, .. }, Container::ListItem { ordered: b, .. }) => {
305                a == b
306            }
307            _ => self.same_run(other),
308        }
309    }
310}
311
312/// A [`Content`] that [`Content::normalize`] has run on: the precondition both
313/// projections carry. Minted only by [`Content::into_normalized`], which the
314/// codecs decode through; a mutation that does not re-establish the invariant
315/// takes [`into_content`](Self::into_content) and mints again.
316///
317/// ## Canonical, not valid
318///
319/// [`validate`](Content::validate) rejects a disjoint set: what normalization
320/// cannot repair. Nothing it does brings a container path under
321/// [`MAX_NESTING_DEPTH`](crate::MAX_NESTING_DEPTH), so a token can hold a
322/// content `validate` refuses, and the mint stays infallible on that split. The
323/// codecs call `validate` after minting; a Rust embedder hand-building a
324/// [`Content`] may not.
325///
326/// A projection taking a token may therefore assume only what the mint
327/// establishes, and must be **total over any token**:
328/// [`to_markdown`](crate::export::to_markdown) walks containers on an explicit stack
329/// rather than a frame per level, and `emit_content` checks the depth and
330/// returns an error. An unguarded recursion aborts the process, which no
331/// `Result` can catch.
332#[derive(Debug, Clone, PartialEq)]
333pub struct Normalized(Content);
334
335impl Normalized {
336    /// [`Content::empty`], which is already canonical.
337    pub fn empty() -> Normalized {
338        Normalized(Content::empty())
339    }
340
341    pub fn into_content(self) -> Content {
342        self.0
343    }
344
345    /// Every caller must leave this normalized; the forwarded `apply_*` in
346    /// [`crate::ops`] are the ones that do.
347    pub(crate) fn as_content_mut(&mut self) -> &mut Content {
348        &mut self.0
349    }
350}
351
352impl From<Content> for Normalized {
353    fn from(rt: Content) -> Normalized {
354        rt.into_normalized()
355    }
356}
357
358impl std::ops::Deref for Normalized {
359    type Target = Content;
360
361    fn deref(&self) -> &Content {
362        &self.0
363    }
364}
365
366/// A mark over a char range `[start, end)`. `start == end` (zero-width) is legal
367/// only for [`MarkKind::Anchor`]; normalization drops zero-width formatting.
368#[derive(Debug, Clone, PartialEq)]
369pub struct Mark {
370    pub start: Usv,
371    pub end: Usv,
372    pub kind: MarkKind,
373}
374
375impl Mark {
376    pub fn new(start: Usv, end: Usv, kind: MarkKind) -> Self {
377        Mark { start, end, kind }
378    }
379}
380
381/// The mark set, **closed**: a `type` outside it is
382/// [`ParseError::UnknownName`](crate::serial::ParseError::UnknownName). Two
383/// algebra classes: formatting is a property of a range (two coincident are
384/// redundant); identity is a handle (two over the same range are two things).
385#[derive(Debug, Clone, PartialEq)]
386pub enum MarkKind {
387    // Formatting: round-trippable projection marks. `is_formatting()`.
388    Strong,
389    Emph,
390    Underline,
391    Strike,
392    Code,
393    Link {
394        url: String,
395    },
396    // Identity: a handle, not a property. Never merged, may be zero-width.
397    /// A comment thread or stable anchor, carried by id and rebased across
398    /// edits like any position. The id is caller-supplied, unique per `Content`,
399    /// and invariant while the mark lives; moved-and-rewritten text drops the
400    /// mark whole. No markdown projection: it is omitted on export and survives
401    /// via diff-rebase.
402    Anchor {
403        id: String,
404    },
405}
406
407/// A structured object with no honest text encoding (a table, figure, or future
408/// embed) occupying one [`ISLAND_SLOT`] in the content.
409#[derive(Debug, Clone, PartialEq)]
410pub struct Island {
411    /// Deterministically minted, session-stable id: `isl-{n}` by import
412    /// position. Part of the canonical form and thus hash input, so it is never
413    /// ambient. Edits keep it stable rather than re-deriving it, so
414    /// [`Content::validate`] enforces uniqueness, not positional equality.
415    pub id: String,
416    /// Island type discriminator, closed: see [`IslandType`].
417    pub island_type: IslandType,
418    /// Typed payload. Recursively key-sorted by normalization so it hashes
419    /// deterministically despite `serde_json`'s `preserve_order`.
420    pub props: JsonValue,
421    /// How faithfully the markdown projection can carry this island.
422    pub loss: Loss,
423}
424
425impl Island {
426    /// An island of `island_type` under `id`, carrying no payload and claiming
427    /// no projection loss, which is also what the wire reads off the absent
428    /// keys.
429    pub fn new(id: String, island_type: IslandType) -> Self {
430        Island {
431            id,
432            island_type,
433            props: JsonValue::Null,
434            loss: Loss::Lossless,
435        }
436    }
437
438    pub fn with_props(mut self, props: JsonValue) -> Self {
439        self.props = props;
440        self
441    }
442
443    pub fn with_loss(mut self, loss: Loss) -> Self {
444        self.loss = loss;
445        self
446    }
447}
448
449/// How faithfully the markdown projection carries an island: a **description**
450/// of what the projection does with it, for a consumer to surface. It is not a
451/// switch: [`crate::export::to_markdown`] dispatches on
452/// [`Island::island_type`], never on this.
453///
454/// Closed: a `loss` outside this set is
455/// [`ParseError::UnknownName`](crate::serial::ParseError::UnknownName), so a
456/// consumer laddering on it has no rung it cannot read.
457#[derive(Debug, Clone, Copy, PartialEq, Eq)]
458pub enum Loss {
459    /// Round-trips identically.
460    Lossless,
461    /// Round-trips visibly, not identically.
462    Degraded,
463    /// No markdown encoding, and where an uninterpretable class lands.
464    Unrepresentable,
465}
466
467impl Loss {
468    /// Every level, faithful first: the one enumeration point.
469    pub const ALL: &'static [Loss] = &[Loss::Lossless, Loss::Degraded, Loss::Unrepresentable];
470
471    /// The wire class naming this level: the one place a class is spelled.
472    pub const fn as_str(self) -> &'static str {
473        match self {
474            Self::Lossless => "lossless",
475            Self::Degraded => "degraded",
476            Self::Unrepresentable => "unrepresentable",
477        }
478    }
479
480    /// Parse a wire class; `parse(l.as_str()) == Some(l)` for every variant.
481    pub fn parse(class: &str) -> Option<Loss> {
482        Self::ALL.iter().copied().find(|f| f.as_str() == class)
483    }
484}
485
486impl MarkKind {
487    /// Formatting marks are a property of a range and union when coincident;
488    /// an identity mark is a handle and never merges.
489    ///
490    /// Class membership is stored meaning, not presentation: moving a member
491    /// *into* this class starts unioning adjacent runs that round-tripped as
492    /// two marks, moving the canonical bytes of documents nobody edited.
493    pub fn is_formatting(&self) -> bool {
494        matches!(
495            self,
496            MarkKind::Strong
497                | MarkKind::Emph
498                | MarkKind::Underline
499                | MarkKind::Strike
500                | MarkKind::Code
501                | MarkKind::Link { .. }
502        )
503    }
504
505    /// The wire `type` name.
506    pub fn tag(&self) -> &'static str {
507        match self {
508            MarkKind::Strong => "strong",
509            MarkKind::Emph => "emph",
510            MarkKind::Underline => "underline",
511            MarkKind::Strike => "strike",
512            MarkKind::Code => "code",
513            MarkKind::Link { .. } => "link",
514            MarkKind::Anchor { .. } => "anchor",
515        }
516    }
517
518    /// The payload bag, one spelling for every member of the vocabulary.
519    pub fn attrs(&self) -> Cow<'_, JsonValue> {
520        match self {
521            MarkKind::Strong
522            | MarkKind::Emph
523            | MarkKind::Underline
524            | MarkKind::Strike
525            | MarkKind::Code => Cow::Owned(JsonValue::Null),
526            MarkKind::Link { url } => Cow::Owned(bag([("url", url.as_str().into())])),
527            MarkKind::Anchor { id } => Cow::Owned(bag([("id", id.as_str().into())])),
528        }
529    }
530
531    /// The canonical sort tie-break after `(start, end)`, and the grouping key
532    /// for same-kind union (two `link`s union only at one url).
533    ///
534    /// It is the pair the **wire** carries, read back off the value, so
535    /// canonical order is a function of the stored bytes rather than of variant
536    /// declaration order: adding a member reorders nothing already stored.
537    ///
538    /// The attrs half comes from [`attrs`](Self::attrs) rather than a string per
539    /// arm, so it cannot disagree with what the encoder writes.
540    pub fn sort_key(&self) -> (String, String) {
541        let attrs = self.attrs();
542        let attrs = if is_empty_bag(&attrs) {
543            String::new()
544        } else {
545            canonical_json_string(&attrs)
546        };
547        (self.tag().to_string(), attrs)
548    }
549}
550
551/// A `serde_json::Value` rendered to a string with object keys recursively
552/// sorted: order-insensitive, so it is a stable comparison/grouping key.
553pub(crate) fn canonical_json_string(v: &JsonValue) -> String {
554    if is_value_key_sorted(v) {
555        return serde_json::to_string(v).unwrap_or_default();
556    }
557    serde_json::to_string(&sort_keys_owned(v.clone())).unwrap_or_default()
558}
559
560/// Whether every object in `v` already has its keys in ascending order,
561/// recursively: the allocation-free check that lets a re-normalize skip
562/// rebuilding an already-canonical tree.
563pub(crate) fn is_value_key_sorted(v: &JsonValue) -> bool {
564    match v {
565        JsonValue::Array(items) => items.iter().all(is_value_key_sorted),
566        JsonValue::Object(map) => {
567            map.keys().zip(map.keys().skip(1)).all(|(a, b)| a <= b)
568                && map.values().all(is_value_key_sorted)
569        }
570        _ => true,
571    }
572}
573
574/// `true` when `v` nests deeper than `max` container levels: the guard that
575/// keeps the recursive walkers here and `Value`'s own `Drop` inside a bounded
576/// frame count. The walk is iterative, so the check itself cannot overflow on
577/// the adversarially deep input it exists to detect.
578///
579/// The unit is **container levels**, not nodes: only arrays/objects are charged
580/// a level and a scalar leaf is never checked, so an empty container at level
581/// `max + 1` is rejected exactly like a full one. `quillmark_core` re-exports
582/// this as its own depth guard, so every boundary rejects the identical shape.
583pub fn json_depth_exceeds(v: &JsonValue, max: usize) -> bool {
584    // (value, depth) pairs; depth counts container levels entered.
585    let mut stack: Vec<(&JsonValue, usize)> = vec![(v, 0)];
586    while let Some((v, depth)) = stack.pop() {
587        match v {
588            JsonValue::Array(items) => {
589                if depth + 1 > max {
590                    return true;
591                }
592                stack.extend(items.iter().map(|c| (c, depth + 1)));
593            }
594            JsonValue::Object(map) => {
595                if depth + 1 > max {
596                    return true;
597                }
598                stack.extend(map.values().map(|c| (c, depth + 1)));
599            }
600            _ => {}
601        }
602    }
603    false
604}
605
606/// [`json_depth_exceeds`] against [`MAX_JSON_DEPTH`](crate::MAX_JSON_DEPTH) as
607/// an [`Invariant`] result, `what` naming the bag.
608pub(crate) fn check_json_depth(v: &JsonValue, what: &'static str) -> Result<(), Invariant> {
609    if json_depth_exceeds(v, crate::MAX_JSON_DEPTH) {
610        return Err(Invariant::JsonTooDeep {
611            what,
612            max: crate::MAX_JSON_DEPTH,
613        });
614    }
615    Ok(())
616}
617
618/// Put `v` in canonical key order, rebuilding it only when a key is out of
619/// order, so an untouched tree pays the scan and skips the deep clone.
620///
621/// Both walks below recurse, where the container walks do not: a container path
622/// is flat memory at any depth, so nothing but the walk bounds it, while a
623/// [`JsonValue`] deep enough to overflow these overflows its own `Drop` in the
624/// frame that built it. The bound belongs where such a value enters —
625/// `bag_from_wire` before the decode clone, [`check_json_depth`] in
626/// [`Content::validate`].
627pub(crate) fn canonicalize_keys(v: &mut JsonValue) {
628    if !is_value_key_sorted(v) {
629        *v = sort_keys_owned(std::mem::take(v));
630    }
631}
632
633/// Reorder every object's keys by **moving** each entry into a freshly
634/// key-sorted map, recursively. Pins the canonical bytes against
635/// `serde_json`'s `preserve_order` leaking insertion order; rebuilding the map
636/// (rather than sorting in place) keeps that independent of whether the feature
637/// is on in the crate graph.
638pub(crate) fn sort_keys_owned(v: JsonValue) -> JsonValue {
639    match v {
640        JsonValue::Array(items) => {
641            JsonValue::Array(items.into_iter().map(sort_keys_owned).collect())
642        }
643        JsonValue::Object(map) => {
644            let mut entries: Vec<(String, JsonValue)> = map.into_iter().collect();
645            entries.sort_by(|a, b| a.0.cmp(&b.0));
646            let mut out = serde_json::Map::with_capacity(entries.len());
647            for (k, child) in entries {
648                out.insert(k, sort_keys_owned(child));
649            }
650            JsonValue::Object(out)
651        }
652        other => other,
653    }
654}
655
656/// What a [`Content`] can hold that [`Content::normalize`] cannot repair.
657/// Returned by [`Content::validate`].
658///
659/// Each names a shape the model has no principled rewrite for: a forbidden
660/// character with no substitute, two counts with no rule saying which is right,
661/// a range or depth past a bound, an id whose collision only its author can
662/// settle. What normalization *does* repair is not here — the mint establishes
663/// it, and nothing re-checks it.
664#[derive(Debug, Clone, PartialEq, Eq)]
665pub enum Invariant {
666    /// `\r` in the text (line endings must be normalized to `\n`).
667    CarriageReturn,
668    /// A bidi formatting control in the text.
669    BidiControl(char),
670    /// A line separator in the text — VT, FF, NEL, U+2028 or U+2029 — which a
671    /// downstream lexer reads as a line break.
672    LineSeparator(char),
673    /// `island_slot_count != islands.len()`.
674    IslandSlotMismatch { slots: usize, islands: usize },
675    /// `lines.len() != newline_segment_count`.
676    LineCountMismatch { lines: usize, segments: usize },
677    /// A mark range runs past the content or is inverted (`start > end`).
678    /// Clamping would guess which end the author meant.
679    MarkOutOfRange { start: Usv, end: Usv, len: Usv },
680    /// A heading level outside 1..=6. No rewrite is principled: level 0 exports
681    /// as bare text and level 7 as literal hashes, and a clamp guesses the
682    /// other way. Both wires refuse it ahead of the model
683    /// ([`ParseError::Shape`](crate::serial::ParseError::Shape)), so only a Rust
684    /// caller spelling the level reaches this.
685    BadHeadingLevel(u8),
686    /// Two islands share an `id`. Uniqueness is the id invariant `validate`
687    /// enforces; positional equality is not, since edits keep an island's id
688    /// stable across renumbers.
689    IslandIdCollision { id: String },
690    /// Two prose anchors share an `id`, or one carries the empty id.
691    /// `RemoveAnchor { id }` retains-out *every* match, so a shared id makes
692    /// removing one destroy both. Scope is prose marks: cell anchors are outside
693    /// the op surface.
694    AnchorIdCollision { id: String },
695    /// A line's container path is nested deeper than
696    /// [`MAX_NESTING_DEPTH`](crate::MAX_NESTING_DEPTH). The Typst emitter
697    /// recurses one frame per container and refuses a deeper path rather than
698    /// overflow the stack; markdown export walks an explicit stack and projects
699    /// any depth.
700    NestingTooDeep {
701        line: usize,
702        depth: usize,
703        max: usize,
704    },
705    /// An opaque JSON payload (an island's `props`) nests deeper than
706    /// [`MAX_JSON_DEPTH`](crate::MAX_JSON_DEPTH). `what` names the bag; no true
707    /// depth is reported, since the check bails at the first over-deep
708    /// container.
709    JsonTooDeep { what: &'static str, max: usize },
710}
711
712/// Whether a line's text contradicts its `kind`, which [`Content::normalize`]
713/// answers by demoting the line to [`LineKind::Para`].
714///
715/// `Para` and `Heading` carry arbitrary text including slots (an inline image is
716/// a slot in a `Para`), so only the three kinds whose contract *names* their
717/// content constrain it: an [`Island`](LineKind::Island) line is exactly one
718/// [`ISLAND_SLOT`], a [`Rule`](LineKind::Rule) carries no text, and a
719/// [`Code`](LineKind::Code) line carries no slot — a fence emits its text
720/// verbatim, so a slot would land raw in the output and re-import as nothing,
721/// taking the island with it.
722pub(crate) fn line_kind_contradicts_text(kind: &LineKind, seg: &str) -> bool {
723    match kind {
724        LineKind::Island => {
725            let mut chars = seg.chars();
726            !matches!((chars.next(), chars.next()), (Some(ISLAND_SLOT), None))
727        }
728        LineKind::Rule => !seg.is_empty(),
729        LineKind::Code { .. } => seg.contains(ISLAND_SLOT),
730        _ => false,
731    }
732}
733
734/// Whether `[start, end)` is a whole line of `chars`: a line boundary on each
735/// side and nothing else between them. An empty range asks it of the position a
736/// splice would fill.
737pub(crate) fn is_whole_line(chars: &[char], start: Usv, end: Usv) -> bool {
738    (start == 0 || chars.get(start - 1) == Some(&'\n')) && matches!(chars.get(end), None | Some('\n'))
739}
740
741/// Every block-only island's slot that shares its line with other content, in
742/// text order: the single reading behind the authored lane's refusal and the
743/// break [`Content::normalize`] performs, so the two cannot drift.
744pub(crate) fn inline_block_islands<'a>(
745    chars: &'a [char],
746    islands: &'a [Island],
747) -> impl Iterator<Item = Usv> + 'a {
748    chars
749        .iter()
750        .enumerate()
751        .filter(|&(_, &c)| c == ISLAND_SLOT)
752        .zip(islands)
753        .filter(|&((at, _), island)| {
754            island.island_type.block_only() && !is_whole_line(chars, at, at + 1)
755        })
756        .map(|((at, _), _)| at)
757}
758
759/// One piece of a line [`Content::split_block_islands`] broke, covering `span`:
760/// the slot's own piece is the island's line, the prose pieces keep the line's
761/// role, and only the first can still continue the block above.
762fn fragment_line(line: &Line, span: std::ops::Range<Usv>, breaks: &[Usv], first: bool) -> Line {
763    if span.len() == 1 && breaks.binary_search(&span.start).is_ok() {
764        return Line {
765            kind: LineKind::Island,
766            containers: line.containers.clone(),
767            continues: false,
768        };
769    }
770    Line {
771        kind: line.kind.clone(),
772        containers: line.containers.clone(),
773        continues: first && line.continues,
774    }
775}
776
777/// The [`LineKind`] a line whose sole content is one [`ISLAND_SLOT`] carries in
778/// canonical form: [`LineKind::Island`] where markdown writes that island as a
779/// block ([`IslandType::block_only`](crate::island::IslandType::block_only)),
780/// [`LineKind::Para`] where it writes it inline. Both spell one markdown, so the
781/// model keeps the one re-importing it yields, and [`Content::normalize`] writes
782/// that one.
783///
784/// `None` leaves the stored kind standing, on the three counts the projection
785/// settles nothing: a line holding more than the slot, a kind whose own contract
786/// carries a slot ([`LineKind::Heading`]), and an island whose type projects no
787/// kind back.
788fn island_line_kind(kind: &LineKind, seg: &str, island: Option<&Island>) -> Option<LineKind> {
789    if !matches!(kind, LineKind::Para | LineKind::Island) {
790        return None;
791    }
792    let mut chars = seg.chars();
793    if (chars.next(), chars.next()) != (Some(ISLAND_SLOT), None) {
794        return None;
795    }
796    let known = island?.island_type;
797    Some(if known.block_only() {
798        LineKind::Island
799    } else {
800        LineKind::Para
801    })
802}
803
804impl Content {
805    /// The text and its per-line attributes; marks and islands start empty.
806    ///
807    /// Constructing neither normalizes nor checks: the canonical form is the
808    /// caller's until [`into_normalized`](Self::into_normalized) runs, and
809    /// [`validate`](Self::validate) reports what that cannot repair. The codecs
810    /// ([`crate::import`], [`Content::from_canonical_json`]) do both.
811    pub fn new(text: String, lines: Vec<Line>) -> Self {
812        Content {
813            text,
814            lines,
815            marks: Vec::new(),
816            islands: Vec::new(),
817        }
818    }
819
820    /// Normalize and seal. With [`Normalized::empty`], the only mint for
821    /// [`Normalized`]; the codecs decode through here.
822    pub fn into_normalized(mut self) -> Normalized {
823        self.normalize();
824        Normalized(self)
825    }
826
827    pub fn with_marks(mut self, marks: Vec<Mark>) -> Self {
828        self.marks = marks;
829        self
830    }
831
832    /// Set the islands, one per [`ISLAND_SLOT`] in slot order.
833    pub fn with_islands(mut self, islands: Vec<Island>) -> Self {
834        self.islands = islands;
835        self
836    }
837
838    /// An empty content: one empty `Para` line, no marks, no islands.
839    pub fn empty() -> Self {
840        Content::new(String::new(), vec![Line::new(LineKind::Para)])
841    }
842
843    /// Total length in USV.
844    pub fn len_usv(&self) -> Usv {
845        self.text.chars().count()
846    }
847
848    /// Whether this content satisfies the `richtext(inline)` constraint: exactly
849    /// one `Para` line, sitting in no container, with no islands.
850    /// [`Content::empty`] is inline, so a blank inline field passes.
851    pub fn is_inline(&self) -> bool {
852        self.islands.is_empty()
853            && self.lines.len() == 1
854            && self.lines[0].kind == LineKind::Para
855            && self.lines[0].containers.is_empty()
856    }
857
858    /// Whether this content satisfies the `plaintext` constraint: no marks, no
859    /// islands, and every line a plain `Para` sitting in no container.
860    /// `continues` is unconstrained. [`Content::empty`] is plain.
861    ///
862    /// The distinguishing property of plaintext over `richtext { marks: [] }` is
863    /// the *literal* codec ([`crate::import::from_plaintext`]), not this
864    /// predicate.
865    pub fn is_plain(&self) -> bool {
866        self.marks.is_empty()
867            && self.islands.is_empty()
868            && self
869                .lines
870                .iter()
871                .all(|l| l.kind == LineKind::Para && l.containers.is_empty())
872    }
873
874    /// Whether the text is empty or whitespace-only. An [`ISLAND_SLOT`] is not
875    /// whitespace, so an island-bearing content is never blank.
876    pub fn is_blank(&self) -> bool {
877        self.text.trim().is_empty()
878    }
879
880    /// Number of `\n`-separated segments: the required `lines.len()`.
881    pub fn segment_count(&self) -> usize {
882        self.text.chars().filter(|c| *c == '\n').count() + 1
883    }
884
885    /// Normalize in place: canonicalize container `ordinal`/`instance`, break a
886    /// line around a block-only island's slot, drop zero-width formatting, union
887    /// same-kind formatting that is adjacent or overlapping, recursively
888    /// key-sort island props, then sort marks canonically. Idempotent: the
889    /// fixed point the canonical serialization commits to.
890    pub fn normalize(&mut self) {
891        canonicalize_containers(&mut self.lines);
892        // A splice writes text, never kinds: typing into a table line leaves it
893        // `Island` over prose, joining a fence to an image line leaves it `Code`
894        // over a slot, and export reads the kind and not the text, so the
895        // un-repaired line projects its content away. Demote to `Para`, which is
896        // what re-importing the line's own markdown yields.
897        let mut slot = 0usize;
898        for (line, seg) in self.lines.iter_mut().zip(self.text.split('\n')) {
899            if line_kind_contradicts_text(&line.kind, seg) {
900                line.kind = LineKind::Para;
901            }
902            if let Some(kind) = island_line_kind(&line.kind, seg, self.islands.get(slot)) {
903                line.kind = kind;
904            }
905            slot += seg.chars().filter(|&c| c == ISLAND_SLOT).count();
906        }
907        self.split_block_islands();
908        // A `continues` flag under a block that cannot take one clears: nothing
909        // precedes the first line, and below it a differing container path or a
910        // one-line kind above, where export would drop the continuation's text.
911        // `Join` across two paths, `SetKind` retagging the line above and
912        // `SetContinues` itself all reach the shape. Read after the demotion
913        // above, which settles what a spliced-over kind is.
914        for i in 0..self.lines.len() {
915            if self.lines[i].continues
916                && (i == 0
917                    || self.lines[i].containers != self.lines[i - 1].containers
918                    || !self.lines[i - 1].kind.takes_continuations())
919            {
920                self.lines[i].continues = false;
921            }
922        }
923        // A table island's props are repaired (padded to one column count, cell
924        // `\n` rewritten to a space, cell marks canonicalized) before the key
925        // sort, so equal cells serialize to equal bytes.
926        for island in &mut self.islands {
927            island.island_type.normalize_props(&mut island.props);
928            canonicalize_keys(&mut island.props);
929        }
930        // A formatting mark's edges never sit on a line boundary: markdown can't
931        // bold a `\n`, so two producers that disagree only about whether the
932        // boundary is "inside" the mark must canonicalize to the same bounds.
933        // Trim leading/trailing `\n` (interior boundaries are kept: a mark may
934        // legitimately span lines). Zero-width results are dropped below.
935        // Skip the full-text char collection when nothing needs trimming.
936        if self.marks.iter().any(|m| m.kind.is_formatting()) {
937            let chars: Vec<char> = self.text.chars().collect();
938            for m in &mut self.marks {
939                if m.kind.is_formatting() {
940                    while m.start < m.end && chars.get(m.start) == Some(&'\n') {
941                        m.start += 1;
942                    }
943                    while m.end > m.start && chars.get(m.end - 1) == Some(&'\n') {
944                        m.end -= 1;
945                    }
946                }
947            }
948        }
949        self.marks = normalize_marks(std::mem::take(&mut self.marks));
950    }
951
952    /// Give a block-only island's slot the line its markup needs: a `\n` on each
953    /// side that has other content, so the prose around it becomes its own block
954    /// and the slot is alone. Markdown writes a table as a block, and left
955    /// inline it emits pipes into the middle of a paragraph, which re-imports as
956    /// prose with the island gone.
957    ///
958    /// The lanes that *author* an island refuse the placement up front
959    /// ([`ApplyError::BlockIslandNotAlone`](crate::ops::ApplyError::BlockIslandNotAlone)),
960    /// so what reaches here is a stored blob carrying the shape and an accepted
961    /// `Join` that ran a slot back into its prose.
962    fn split_block_islands(&mut self) {
963        use crate::delta::{Delta, Op};
964
965        if !self.islands.iter().any(|i| i.island_type.block_only())
966            || self.lines.len() != self.segment_count()
967        {
968            return;
969        }
970        let chars: Vec<char> = self.text.chars().collect();
971        let breaks: Vec<Usv> = inline_block_islands(&chars, &self.islands).collect();
972        if breaks.is_empty() {
973            return;
974        }
975        // Where the `\n` goes, in the text's own coordinates, so every cut sits
976        // strictly inside a line and the fragments below stay in step with it.
977        let mut cuts: Vec<Usv> = Vec::with_capacity(breaks.len() * 2);
978        for &at in &breaks {
979            if at > 0 && chars[at - 1] != '\n' {
980                cuts.push(at);
981            }
982            if chars.get(at + 1).is_some_and(|&c| c != '\n') {
983                cuts.push(at + 1);
984            }
985        }
986        cuts.dedup(); // two adjacent slots name the boundary between them twice
987
988        let mut lines = Vec::with_capacity(self.lines.len() + cuts.len());
989        let mut cut = cuts.iter().copied().peekable();
990        let mut pos = 0usize;
991        for (line, seg) in self.lines.iter().zip(self.text.split('\n')) {
992            let end = pos + seg.chars().count();
993            let mut start = pos;
994            let mut first = true;
995            while let Some(p) = cut.next_if(|&p| p < end) {
996                lines.push(fragment_line(line, start..p, &breaks, first));
997                (start, first) = (p, false);
998            }
999            lines.push(fragment_line(line, start..end, &breaks, first));
1000            pos = end + 1;
1001        }
1002
1003        let mut text = String::with_capacity(self.text.len() + cuts.len());
1004        let mut at_cut = cuts.iter().copied().peekable();
1005        for (i, &c) in chars.iter().enumerate() {
1006            if at_cut.next_if_eq(&i).is_some() {
1007                text.push('\n');
1008            }
1009            text.push(c);
1010        }
1011        let mut ops = Vec::with_capacity(cuts.len() * 2);
1012        let mut last = 0usize;
1013        for &p in &cuts {
1014            ops.push(Op::Retain(p - last));
1015            ops.push(Op::Insert("\n".to_string()));
1016            last = p;
1017        }
1018
1019        self.text = text;
1020        self.lines = lines;
1021        self.rebase_marks(&Delta { ops });
1022    }
1023
1024    /// What the mint cannot repair. `Ok(())` on every content a codec or an
1025    /// accepted op hands out; a hand-built one can fail it.
1026    pub fn validate(&self) -> Result<(), Invariant> {
1027        let mut slots = 0usize;
1028        let mut newlines = 0usize;
1029        let mut len: Usv = 0;
1030        for c in self.text.chars() {
1031            if c == '\r' {
1032                return Err(Invariant::CarriageReturn);
1033            }
1034            if is_bidi_char(c) {
1035                return Err(Invariant::BidiControl(c));
1036            }
1037            if is_line_separator(c) {
1038                return Err(Invariant::LineSeparator(c));
1039            }
1040            if c == ISLAND_SLOT {
1041                slots += 1;
1042            }
1043            if c == '\n' {
1044                newlines += 1;
1045            }
1046            len += 1;
1047        }
1048        if slots != self.islands.len() {
1049            return Err(Invariant::IslandSlotMismatch {
1050                slots,
1051                islands: self.islands.len(),
1052            });
1053        }
1054        let segments = newlines + 1;
1055        if self.lines.len() != segments {
1056            return Err(Invariant::LineCountMismatch {
1057                lines: self.lines.len(),
1058                segments,
1059            });
1060        }
1061        // Anchor-id uniqueness is what `RemoveAnchor` presumes.
1062        let mut seen_anchor_ids = std::collections::HashSet::new();
1063        for m in &self.marks {
1064            if m.start > m.end || m.end > len {
1065                return Err(Invariant::MarkOutOfRange {
1066                    start: m.start,
1067                    end: m.end,
1068                    len,
1069                });
1070            }
1071            if let MarkKind::Anchor { id } = &m.kind
1072                && (id.is_empty() || !seen_anchor_ids.insert(id.as_str()))
1073            {
1074                return Err(Invariant::AnchorIdCollision { id: id.clone() });
1075            }
1076        }
1077        for (i, line) in self.lines.iter().enumerate() {
1078            match &line.kind {
1079                LineKind::Heading { level } if !(1..=6).contains(level) => {
1080                    return Err(Invariant::BadHeadingLevel(*level));
1081                }
1082                _ => {}
1083            }
1084            if line.containers.len() > crate::MAX_NESTING_DEPTH {
1085                return Err(Invariant::NestingTooDeep {
1086                    line: i,
1087                    depth: line.containers.len(),
1088                    max: crate::MAX_NESTING_DEPTH,
1089                });
1090            }
1091        }
1092        // Table-cell marks: the prose range rule again, but each mark is bounded
1093        // by its own cell's text length (in USV).
1094        let mut seen_ids = std::collections::HashSet::with_capacity(self.islands.len());
1095        for island in &self.islands {
1096            if !seen_ids.insert(island.id.as_str()) {
1097                return Err(Invariant::IslandIdCollision {
1098                    id: island.id.clone(),
1099                });
1100            }
1101            // Depth before any pass that walks `props`; a cell's own `attrs` is
1102            // a subtree, so this bounds the cell marks read below as well.
1103            check_json_depth(&island.props, "island props")?;
1104            for (text, marks) in island.island_type.cell_marks(&island.props) {
1105                let clen = text.chars().count();
1106                for m in &marks {
1107                    if m.start > m.end || m.end > clen {
1108                        return Err(Invariant::MarkOutOfRange {
1109                            start: m.start,
1110                            end: m.end,
1111                            len: clen,
1112                        });
1113                    }
1114                }
1115            }
1116        }
1117        Ok(())
1118    }
1119}
1120
1121/// One open container run, while [`canonicalize_containers`] walks past it.
1122struct Run {
1123    /// The container **as stored** at the line that opened this run, which is
1124    /// what decides where the input's runs begin: a producer's own `instance`
1125    /// values separate its runs whatever they are, and only their canonical
1126    /// spelling is this pass's business. Cloned once per run, not per line.
1127    raw: Container,
1128    instance: u64,
1129    ordinal: u64,
1130    raw_ordinal: u64,
1131}
1132
1133/// Canonicalize every container path: `instance` to the minimal discriminator
1134/// the adjacency needs, `ordinal` to a gapless 0-based index.
1135///
1136/// Both are derived from *run structure*, which the stored path already spells:
1137/// a run opens where the stored run key or the stored `instance` changes, and
1138/// within one list item `ordinal` repeating continues that item across its
1139/// paragraphs while any change opens the next. So `[5, 9]` and `[0, 1]` are the
1140/// same two items, `[3, 3, 7]` is two items the first of which spans two
1141/// paragraphs, and a producer's `instance: 7, 9` pair reads as the same two
1142/// runs as `0, 1`.
1143///
1144/// `instance` resets to 0 wherever the preceding sibling run could not weld
1145/// with this one anyway — a different container kind, an intervening block, a
1146/// fresh parent — so it stays 0 in every document that needs no discriminator.
1147fn canonicalize_containers(lines: &mut [Line]) {
1148    let mut state: Vec<Run> = Vec::new();
1149    for line in lines.iter_mut() {
1150        let depth_len = line.containers.len();
1151        // Once a depth opens a new run, every depth below it is under a fresh
1152        // parent, so nothing there can be continuing a run and nothing there
1153        // has an adjacent predecessor to be told apart from.
1154        let mut opened_above = false;
1155        for d in 0..depth_len {
1156            let here = &line.containers[d];
1157            let raw_ordinal = match here {
1158                Container::ListItem { ordinal, .. } => *ordinal,
1159                _ => 0,
1160            };
1161            let continues = !opened_above
1162                && state
1163                    .get(d)
1164                    .is_some_and(|r| r.raw.same_run(here) && r.raw.instance() == here.instance());
1165            if continues {
1166                let run = &mut state[d];
1167                if raw_ordinal != run.raw_ordinal {
1168                    run.ordinal += 1;
1169                    run.raw_ordinal = raw_ordinal;
1170                    // The run continues but the *item* changed, and an item is
1171                    // a parent: everything below is inside a different one, so
1172                    // it neither continues its predecessor nor has an adjacent
1173                    // sibling to be told apart from. Two inner lists under two
1174                    // outer items are two lists however alike they look.
1175                    state.truncate(d + 1);
1176                    opened_above = true;
1177                }
1178            } else {
1179                // The run being replaced is this one's adjacent predecessor,
1180                // and only then: a fresh parent above leaves none.
1181                let instance = match state.get(d) {
1182                    Some(prev) if !opened_above && prev.raw.same_weld(here) => 1 - prev.instance,
1183                    _ => 0,
1184                };
1185                let raw = here.clone();
1186                state.truncate(d);
1187                state.push(Run {
1188                    raw,
1189                    instance,
1190                    ordinal: 0,
1191                    raw_ordinal,
1192                });
1193                opened_above = true;
1194            }
1195            let (ordinal, instance) = (state[d].ordinal, state[d].instance);
1196            if let Container::ListItem { ordinal: o, .. } = &mut line.containers[d] {
1197                *o = ordinal;
1198            }
1199            line.containers[d].set_instance(instance);
1200        }
1201        state.truncate(depth_len);
1202    }
1203}
1204
1205/// Apply the three merge rules and the canonical sort to a flat mark list:
1206/// same-kind formatting marks union when adjacent *or* overlapping, different
1207/// kinds overlap freely (never split into runs), and an identity mark never
1208/// merges. Zero-width formatting is dropped; zero-width anchors survive.
1209pub(crate) fn normalize_marks(marks: Vec<Mark>) -> Vec<Mark> {
1210    use std::collections::BTreeMap;
1211
1212    let mut groups: BTreeMap<(String, String), Vec<(Usv, Usv)>> = BTreeMap::new();
1213    let mut kind_of: BTreeMap<(String, String), MarkKind> = BTreeMap::new();
1214    let mut passthrough: Vec<Mark> = Vec::new();
1215
1216    for m in marks {
1217        if m.kind.is_formatting() {
1218            if m.start >= m.end {
1219                continue; // drop zero-width / inverted formatting
1220            }
1221            let key = m.kind.sort_key();
1222            kind_of.entry(key.clone()).or_insert_with(|| m.kind.clone());
1223            groups.entry(key).or_default().push((m.start, m.end));
1224        } else {
1225            passthrough.push(m);
1226        }
1227    }
1228
1229    let mut out: Vec<Mark> = Vec::new();
1230    for (key, mut ranges) in groups {
1231        ranges.sort_unstable();
1232        let kind = kind_of.remove(&key).expect("kind recorded with group");
1233        let mut cur = ranges[0];
1234        for &(s, e) in &ranges[1..] {
1235            if s <= cur.1 {
1236                // adjacent (s == cur.1) or overlapping: union
1237                cur.1 = cur.1.max(e);
1238            } else {
1239                out.push(Mark {
1240                    start: cur.0,
1241                    end: cur.1,
1242                    kind: kind.clone(),
1243                });
1244                cur = (s, e);
1245            }
1246        }
1247        out.push(Mark {
1248            start: cur.0,
1249            end: cur.1,
1250            kind,
1251        });
1252    }
1253    out.extend(passthrough);
1254
1255    // Key cached per mark so `sort_key`'s allocation runs once each, not once
1256    // per comparison.
1257    out.sort_by_cached_key(|m| (m.start, m.end, m.kind.sort_key()));
1258    // Two marks equal in range, kind and attrs are one handle recorded twice:
1259    // redundant bytes, not two handles. The sort makes any such pair adjacent.
1260    out.dedup();
1261    out
1262}
1263
1264#[cfg(test)]
1265mod tests {
1266    use super::*;
1267
1268    fn f(start: Usv, end: Usv, kind: MarkKind) -> Mark {
1269        Mark { start, end, kind }
1270    }
1271
1272
1273    #[test]
1274    fn is_blank_tracks_whitespace_and_islands() {
1275        assert!(Content::empty().is_blank());
1276        let mut ws = Content::empty();
1277        ws.text = "  \n\t ".to_string();
1278        ws.lines = vec![
1279            Line {
1280                kind: LineKind::Para,
1281                containers: Vec::new(),
1282                continues: false,
1283            },
1284            Line {
1285                kind: LineKind::Para,
1286                containers: Vec::new(),
1287                continues: false,
1288            },
1289        ];
1290        assert!(ws.is_blank(), "whitespace-only text is blank");
1291
1292        let mut has_text = Content::empty();
1293        has_text.text = "x".to_string();
1294        assert!(!has_text.is_blank());
1295
1296        let mut island_only = Content::empty();
1297        island_only.text = ISLAND_SLOT.to_string();
1298        assert!(!island_only.is_blank());
1299    }
1300
1301    fn tagged(text: &str, kind: LineKind) -> Content {
1302        Content {
1303            text: text.to_string(),
1304            lines: vec![Line {
1305                kind,
1306                containers: Vec::new(),
1307                continues: false,
1308            }],
1309            marks: Vec::new(),
1310            islands: Vec::new(),
1311        }
1312    }
1313
1314    /// Export trusts the kind and never re-reads the segment, so `Island` over
1315    /// prose would project to the island alone and `Rule` over prose to `---`,
1316    /// the text silently gone. The mint demotes to `Para`, which is what
1317    /// re-importing the line's own markdown yields.
1318    #[test]
1319    fn normalize_demotes_a_stranded_line_kind() {
1320        for (text, kind) in [
1321            ("typed into a table line", LineKind::Island),
1322            ("", LineKind::Island),
1323            ("text on a rule line", LineKind::Rule),
1324        ] {
1325            let mut rt = tagged(text, kind.clone());
1326            rt.normalize();
1327            assert_eq!(rt.lines[0].kind, LineKind::Para, "{text:?} as {kind:?}");
1328            assert_eq!(rt.validate(), Ok(()));
1329        }
1330
1331        // `Para`/`Heading` carry slots, so only a fence, whose text is emitted
1332        // verbatim, strands one.
1333        let mut code = tagged(&format!("a{ISLAND_SLOT}b"), LineKind::Code { lang: None });
1334        code.islands = vec![Island {
1335            id: "isl-0".into(),
1336            island_type: IslandType::Image,
1337            props: serde_json::json!({"alt": "x", "url": "y.png"}),
1338            loss: Loss::Lossless,
1339        }];
1340        for (kind, settles_to) in [
1341            (LineKind::Code { lang: None }, LineKind::Para),
1342            (LineKind::Para, LineKind::Para),
1343            (LineKind::Heading { level: 1 }, LineKind::Heading { level: 1 }),
1344        ] {
1345            let mut rt = code.clone();
1346            rt.lines[0].kind = kind.clone();
1347            rt.normalize();
1348            assert_eq!(rt.lines[0].kind, settles_to, "a slot under {kind:?}");
1349            assert_eq!(rt.validate(), Ok(()));
1350        }
1351
1352        // A well-formed island line — a block island's slot alone — is left alone.
1353        let mut rt = tagged(&ISLAND_SLOT.to_string(), LineKind::Island);
1354        rt.islands = vec![table_island()];
1355        rt.normalize();
1356        assert_eq!(rt.lines[0].kind, LineKind::Island);
1357        assert_eq!(rt.validate(), Ok(()));
1358        assert_eq!(tagged("", LineKind::Rule).validate(), Ok(()));
1359    }
1360
1361    /// A one-cell table: the island type markdown writes as a block.
1362    fn table_island() -> Island {
1363        Island::new("isl-0".into(), IslandType::Table).with_props(serde_json::json!({
1364            "aligns": ["none"],
1365            "header": [{"marks": [], "text": "h"}],
1366            "rows": [[{"marks": [], "text": "c"}]],
1367        }))
1368    }
1369
1370    /// Markdown spells a slot-alone `Para` line and a slot-alone `Island` line
1371    /// alike, so which one a document holds is the island type's to settle. Both
1372    /// spellings converge on the one the round trip yields, so no document holds
1373    /// a kind its own markdown denies.
1374    #[test]
1375    fn an_island_alone_on_a_line_takes_the_kind_its_type_projects() {
1376        let image = Island::new("isl-0".into(), IslandType::Image)
1377            .with_props(serde_json::json!({"alt": "a", "url": "u"}));
1378        for (island, canonical) in [
1379            (table_island(), LineKind::Island),
1380            (image, LineKind::Para),
1381        ] {
1382            for stored in [LineKind::Para, LineKind::Island] {
1383                let what = format!("{} as {stored:?}", island.island_type.as_str());
1384                let rt = tagged(&ISLAND_SLOT.to_string(), stored)
1385                    .with_islands(vec![island.clone()])
1386                    .into_normalized();
1387                assert_eq!(rt.validate(), Ok(()), "{what}");
1388                assert_eq!(rt.lines[0].kind, canonical, "{what}");
1389                let md = crate::export::to_markdown(&rt);
1390                assert_eq!(
1391                    crate::import::from_markdown(&md).expect("re-imports"),
1392                    rt,
1393                    "{what} is not a fixed point: {md:?}"
1394                );
1395            }
1396        }
1397    }
1398
1399    #[test]
1400    fn container_nesting_is_capped() {
1401        let mut rt = tagged("hi", LineKind::Para);
1402        rt.lines[0].containers = vec![Container::Quote { instance: 0 }; crate::MAX_NESTING_DEPTH];
1403        assert_eq!(rt.validate(), Ok(()));
1404        rt.lines[0].containers.push(Container::Quote { instance: 0 });
1405        assert_eq!(
1406            rt.validate(),
1407            Err(Invariant::NestingTooDeep {
1408                line: 0,
1409                depth: crate::MAX_NESTING_DEPTH + 1,
1410                max: crate::MAX_NESTING_DEPTH,
1411            })
1412        );
1413    }
1414
1415    #[test]
1416    fn json_payload_depth_is_capped() {
1417        let nested = |depth: usize| {
1418            let mut v = JsonValue::Null;
1419            for _ in 0..depth {
1420                v = JsonValue::Array(vec![v]);
1421            }
1422            v
1423        };
1424        let too_deep = |what: &'static str| {
1425            Err(Invariant::JsonTooDeep {
1426                what,
1427                max: crate::MAX_JSON_DEPTH,
1428            })
1429        };
1430
1431        let mut rt = tagged("\u{fffc}", LineKind::Island);
1432        rt.islands = vec![Island {
1433            id: "i1".into(),
1434            island_type: IslandType::Image,
1435            props: nested(crate::MAX_JSON_DEPTH + 1),
1436            loss: Loss::Lossless,
1437        }];
1438        assert_eq!(rt.validate(), too_deep("island props"));
1439    }
1440
1441    #[test]
1442    fn same_kind_adjacent_unions() {
1443        let got = normalize_marks(vec![f(3, 6, MarkKind::Strong), f(0, 3, MarkKind::Strong)]);
1444        assert_eq!(got, vec![f(0, 6, MarkKind::Strong)]);
1445    }
1446
1447    #[test]
1448    fn same_kind_overlapping_unions() {
1449        let got = normalize_marks(vec![f(0, 4, MarkKind::Emph), f(2, 7, MarkKind::Emph)]);
1450        assert_eq!(got, vec![f(0, 7, MarkKind::Emph)]);
1451    }
1452
1453    #[test]
1454    fn different_kinds_overlap_freely() {
1455        let got = normalize_marks(vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]);
1456        assert_eq!(
1457            got,
1458            vec![f(0, 5, MarkKind::Strong), f(2, 7, MarkKind::Emph)]
1459        );
1460    }
1461
1462    #[test]
1463    fn links_union_only_at_same_url() {
1464        let a = MarkKind::Link { url: "a".into() };
1465        let b = MarkKind::Link { url: "b".into() };
1466        let got = normalize_marks(vec![
1467            f(0, 2, a.clone()),
1468            f(2, 4, a.clone()),
1469            f(4, 6, b.clone()),
1470        ]);
1471        assert_eq!(got, vec![f(0, 4, a), f(4, 6, b)]);
1472    }
1473
1474    #[test]
1475    fn identity_never_merges() {
1476        let a = MarkKind::Anchor { id: "c1".into() };
1477        let b = MarkKind::Anchor { id: "c2".into() };
1478        let got = normalize_marks(vec![f(3, 3, a.clone()), f(3, 3, b.clone())]);
1479        assert_eq!(got.len(), 2);
1480        assert!(got.contains(&f(3, 3, a)));
1481        assert!(got.contains(&f(3, 3, b)));
1482    }
1483
1484    #[test]
1485    fn zero_width_formatting_dropped_zero_width_anchor_kept() {
1486        let got = normalize_marks(vec![
1487            f(2, 2, MarkKind::Strong),
1488            f(2, 2, MarkKind::Anchor { id: "x".into() }),
1489        ]);
1490        assert_eq!(got, vec![f(2, 2, MarkKind::Anchor { id: "x".into() })]);
1491    }
1492
1493    #[test]
1494    fn is_inline_accepts_empty_and_single_para() {
1495        assert!(Content::empty().is_inline());
1496        assert!(crate::import::from_markdown("just one line")
1497            .unwrap()
1498            .is_inline());
1499        assert!(crate::import::from_markdown("a *bold* run")
1500            .unwrap()
1501            .is_inline());
1502    }
1503
1504    #[test]
1505    fn is_inline_rejects_blocks_containers_and_islands() {
1506        assert!(!crate::import::from_markdown("one\n\ntwo")
1507            .unwrap()
1508            .is_inline());
1509        assert!(!crate::import::from_markdown("# heading")
1510            .unwrap()
1511            .is_inline());
1512        assert!(!crate::import::from_markdown("- item").unwrap().is_inline());
1513    }
1514
1515    #[test]
1516    fn validate_catches_slot_mismatch() {
1517        let mut rt = Content::empty();
1518        rt.text = "\u{FFFC}".into();
1519        rt.lines = vec![Line {
1520            kind: LineKind::Island,
1521            containers: vec![],
1522            continues: false,
1523        }];
1524        assert_eq!(
1525            rt.validate(),
1526            Err(Invariant::IslandSlotMismatch {
1527                slots: 1,
1528                islands: 0
1529            })
1530        );
1531    }
1532
1533    #[test]
1534    fn validate_catches_line_count() {
1535        let mut rt = Content::empty();
1536        rt.text = "a\nb".into(); // 2 segments, but 1 line
1537        assert_eq!(
1538            rt.validate(),
1539            Err(Invariant::LineCountMismatch {
1540                lines: 1,
1541                segments: 2
1542            })
1543        );
1544    }
1545
1546    /// A within-block break lives inside one container. `Join` mints the
1547    /// crossing shape by merging two lines of differing paths, which leaves the
1548    /// *next* line continuing across the seam; `normalize` clears it.
1549    #[test]
1550    fn continues_across_a_container_boundary_is_cleared() {
1551        let mut rt = Content::new(
1552            "a\nb".to_string(),
1553            vec![
1554                Line::new(LineKind::Para),
1555                Line::new(LineKind::Para)
1556                    .with_containers(vec![Container::Quote { instance: 0 }])
1557                    .with_continues(true),
1558            ],
1559        );
1560        rt.normalize();
1561        assert!(!rt.lines[1].continues, "normalize clears it");
1562        assert_eq!(rt.validate(), Ok(()));
1563
1564        // Equal-length but different containers is the same crossing: two list
1565        // items are two blocks, and a hard break does not span them.
1566        let li = |ordinal| {
1567            vec![Container::ListItem {
1568                ordered: false,
1569                start: 1,
1570                ordinal,
1571                instance: 0,
1572            }]
1573        };
1574        let mut rt = Content::new(
1575            "a\nb".to_string(),
1576            vec![
1577                Line::new(LineKind::Para).with_containers(li(0)),
1578                Line::new(LineKind::Para)
1579                    .with_containers(li(1))
1580                    .with_continues(true),
1581            ],
1582        );
1583        rt.normalize();
1584        assert!(!rt.lines[1].continues);
1585
1586        // The within-container break is untouched: same path, flag kept.
1587        let mut rt = Content::new(
1588            "a\nb".to_string(),
1589            vec![
1590                Line::new(LineKind::Para).with_containers(li(0)),
1591                Line::new(LineKind::Para)
1592                    .with_containers(li(0))
1593                    .with_continues(true),
1594            ],
1595        );
1596        rt.normalize();
1597        assert!(rt.lines[1].continues, "a hard break inside one item survives");
1598        assert_eq!(rt.validate(), Ok(()));
1599    }
1600
1601    /// A heading, an island and a rule render as their own line alone, so a
1602    /// `continues` line after one is text no projection reaches. `SetKind`
1603    /// mints the shape by retagging the line a continuation already follows;
1604    /// `normalize` clears the flag.
1605    #[test]
1606    fn continues_after_a_single_line_block_is_cleared() {
1607        let cases = [
1608            (LineKind::Heading { level: 1 }, "a\nb", "# a\n\nb"),
1609            (LineKind::Island, "\u{FFFC}\nb", "| h |\n| --- |\n| c |\n\nb"),
1610            (LineKind::Rule, "\nb", "***\n\nb"),
1611        ];
1612        for (kind, text, markdown) in cases {
1613            let mut rt = Content::new(
1614                text.to_string(),
1615                vec![
1616                    Line::new(kind.clone()),
1617                    Line::new(LineKind::Para).with_continues(true),
1618                ],
1619            )
1620            .with_islands(match kind {
1621                LineKind::Island => vec![Island::new("isl-0".into(), IslandType::Table)
1622                    .with_props(serde_json::json!({
1623                        "header": [{"text": "h", "marks": []}],
1624                        "rows": [[{"text": "c", "marks": []}]],
1625                        "aligns": ["none"],
1626                    }))],
1627                _ => vec![],
1628            });
1629            rt.normalize();
1630            assert!(!rt.lines[1].continues, "normalize clears it");
1631            assert_eq!(rt.validate(), Ok(()));
1632            assert_eq!(
1633                crate::export::to_markdown(&rt.into_normalized()),
1634                markdown,
1635                "the continuation projects as the paragraph it is"
1636            );
1637        }
1638    }
1639
1640    #[test]
1641    fn normalize_is_idempotent() {
1642        let mut rt = Content::empty();
1643        rt.text = "hello world".into();
1644        rt.marks = vec![
1645            f(6, 11, MarkKind::Strong),
1646            f(0, 5, MarkKind::Strong),
1647            f(0, 5, MarkKind::Emph),
1648        ];
1649        rt.normalize();
1650        let once = rt.marks.clone();
1651        rt.normalize();
1652        assert_eq!(rt.marks, once);
1653        assert_eq!(rt.validate(), Ok(()));
1654    }
1655
1656    #[test]
1657    fn table_cell_marks_normalize_and_are_idempotent() {
1658        fn table(cell_marks: serde_json::Value) -> Content {
1659            let mut rt = Content::empty();
1660            rt.text = ISLAND_SLOT.to_string();
1661            rt.lines = vec![Line {
1662                kind: LineKind::Island,
1663                containers: vec![],
1664                continues: false,
1665            }];
1666            rt.islands = vec![Island {
1667                id: "i".into(),
1668                island_type: IslandType::Table,
1669                props: serde_json::json!({
1670                    "aligns": ["none"],
1671                    "header": [{"text": "abcd", "marks": cell_marks}],
1672                    "rows": [],
1673                }),
1674                loss: Loss::Lossless,
1675            }];
1676            rt
1677        }
1678        let mut a = table(serde_json::json!([
1679            {"start": 2, "end": 4, "type": "strong"},
1680            {"start": 1, "end": 1, "type": "strong"},
1681            {"start": 0, "end": 2, "type": "strong"}
1682        ]));
1683        a.normalize();
1684        assert_eq!(a.validate(), Ok(()));
1685        let cell = &a.islands[0].props["header"][0];
1686        assert_eq!(cell["marks"].as_array().unwrap().len(), 1);
1687        assert_eq!(cell["marks"][0]["start"], 0);
1688        assert_eq!(cell["marks"][0]["end"], 4);
1689        let mut b = table(serde_json::json!([
1690            {"start": 0, "end": 2, "type": "strong"},
1691            {"start": 2, "end": 4, "type": "strong"}
1692        ]));
1693        b.normalize();
1694        let canon = |rt: &Content| rt.clone().into_normalized().to_canonical_json();
1695        assert_eq!(canon(&a), canon(&b));
1696        let once = canon(&a);
1697        a.normalize();
1698        assert_eq!(canon(&a), once);
1699    }
1700
1701    /// A cell is canonicalized in place, so a key this build does not recognize
1702    /// survives. Two columns with one body cell, so `pad_row` mints the second
1703    /// and the pass covers both a carried cell and a synthesized one.
1704    #[test]
1705    fn unrecognized_cell_key_survives_normalize() {
1706        let mut rt = table_rt(serde_json::json!({
1707            "aligns": ["none", "none"],
1708            "header": [{"text": "h", "marks": [], "colspan": 2}, cell("h2")],
1709            "rows": [[cell("a")]],
1710        }));
1711        rt.normalize();
1712        assert_eq!(rt.islands[0].props["header"][0]["colspan"], 2);
1713        assert!(rt.islands[0].props["rows"][0][1].get("colspan").is_none());
1714        assert!(rt
1715            .into_normalized()
1716            .to_canonical_json()
1717            .contains(r#""colspan":2"#));
1718    }
1719
1720    #[test]
1721    fn validate_catches_cell_mark_out_of_range() {
1722        let mut rt = Content::empty();
1723        rt.text = ISLAND_SLOT.to_string();
1724        rt.lines = vec![Line {
1725            kind: LineKind::Island,
1726            containers: vec![],
1727            continues: false,
1728        }];
1729        rt.islands = vec![Island {
1730            id: "i".into(),
1731            island_type: IslandType::Table,
1732            props: serde_json::json!({
1733                "aligns": ["none"],
1734                // "ab" is 2 USV; a mark ending at 5 runs past the cell.
1735                "header": [{"text": "ab", "marks": [{"start": 0, "end": 5, "type": "strong"}]}],
1736                "rows": [],
1737            }),
1738            loss: Loss::Lossless,
1739        }];
1740        assert_eq!(
1741            rt.validate(),
1742            Err(Invariant::MarkOutOfRange {
1743                start: 0,
1744                end: 5,
1745                len: 2
1746            })
1747        );
1748    }
1749
1750    fn table_rt(props: serde_json::Value) -> Content {
1751        let mut rt = Content::empty();
1752        rt.text = ISLAND_SLOT.to_string();
1753        rt.lines = vec![Line {
1754            kind: LineKind::Island,
1755            containers: vec![],
1756            continues: false,
1757        }];
1758        rt.islands = vec![Island {
1759            id: "i".into(),
1760            island_type: IslandType::Table,
1761            props,
1762            loss: Loss::Lossless,
1763        }];
1764        rt
1765    }
1766
1767    fn cell(t: &str) -> serde_json::Value {
1768        serde_json::json!({ "text": t, "marks": [] })
1769    }
1770
1771    /// The widest row (3) drives the header width, so the markdown
1772    /// (header-derived) and Typst (widest-row) projections agree.
1773    #[test]
1774    fn normalize_repairs_table_shape() {
1775        let mut rt = table_rt(serde_json::json!({
1776            "aligns": ["none"],
1777            "header": [cell("h")],
1778            "rows": [
1779                [cell("a"), cell("b"), cell("c")],
1780                [cell("d\ne")],
1781            ],
1782        }));
1783        rt.normalize();
1784        assert_eq!(rt.validate(), Ok(()));
1785
1786        let props = &rt.islands[0].props;
1787        assert_eq!(props["header"].as_array().unwrap().len(), 3);
1788        assert_eq!(props["aligns"].as_array().unwrap().len(), 3);
1789        for row in props["rows"].as_array().unwrap() {
1790            assert_eq!(row.as_array().unwrap().len(), 3);
1791        }
1792        assert_eq!(props["aligns"][2], serde_json::json!("none"));
1793        assert_eq!(props["header"][1]["text"], serde_json::json!(""));
1794        assert_eq!(props["rows"][1][0]["text"], serde_json::json!("d e"));
1795
1796        let canon = |rt: &Content| rt.clone().into_normalized().to_canonical_json();
1797        let once = canon(&rt);
1798        rt.normalize();
1799        assert_eq!(canon(&rt), once);
1800    }
1801
1802    #[test]
1803    fn empty_table_is_valid() {
1804        let mut rt = table_rt(serde_json::json!({
1805            "aligns": [],
1806            "header": [],
1807            "rows": [],
1808        }));
1809        assert_eq!(rt.validate(), Ok(()));
1810        rt.normalize();
1811        assert_eq!(rt.validate(), Ok(()));
1812    }
1813
1814    #[test]
1815    fn non_array_table_header_is_repaired() {
1816        let mut rt = table_rt(serde_json::json!({
1817            "header": "oops",
1818            "aligns": [],
1819            "rows": [],
1820        }));
1821        rt.normalize();
1822        assert_eq!(rt.validate(), Ok(()));
1823        assert_eq!(rt.islands[0].props["header"], serde_json::json!([]));
1824    }
1825
1826    #[test]
1827    fn duplicate_island_id_is_rejected() {
1828        let mut rt = Content::empty();
1829        rt.text = format!("{ISLAND_SLOT}\n{ISLAND_SLOT}");
1830        rt.lines = vec![
1831            Line {
1832                kind: LineKind::Island,
1833                containers: vec![],
1834                continues: false,
1835            },
1836            Line {
1837                kind: LineKind::Island,
1838                containers: vec![],
1839                continues: false,
1840            },
1841        ];
1842        let table = |id: &str| Island {
1843            id: id.into(),
1844            island_type: IslandType::Table,
1845            props: serde_json::json!({ "header": [cell("h")], "aligns": ["none"], "rows": [] }),
1846            loss: Loss::Lossless,
1847        };
1848        rt.islands = vec![table("dup"), table("dup")];
1849        assert_eq!(
1850            rt.validate(),
1851            Err(Invariant::IslandIdCollision { id: "dup".into() })
1852        );
1853        rt.islands = vec![table("a"), table("b")];
1854        assert_eq!(rt.validate(), Ok(()));
1855    }
1856
1857    /// Byte-identical anchors `normalize` already dedupes; this is the
1858    /// surviving collision.
1859    #[test]
1860    fn duplicate_or_empty_anchor_id_is_rejected() {
1861        let mut rt = Content::empty();
1862        rt.text = "abcd".into();
1863        let anchor = |start, end, id: &str| Mark {
1864            start,
1865            end,
1866            kind: MarkKind::Anchor { id: id.into() },
1867        };
1868        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "x")];
1869        assert_eq!(
1870            rt.validate(),
1871            Err(Invariant::AnchorIdCollision { id: "x".into() })
1872        );
1873        rt.marks = vec![anchor(0, 2, "x"), anchor(2, 4, "y")];
1874        assert_eq!(rt.validate(), Ok(()));
1875        rt.marks = vec![anchor(0, 2, "")];
1876        assert_eq!(
1877            rt.validate(),
1878            Err(Invariant::AnchorIdCollision { id: String::new() })
1879        );
1880    }
1881
1882    #[test]
1883    fn normalize_dedupes_identical_identity_marks() {
1884        let mut rt = Content::empty();
1885        rt.text = "abcd".into();
1886        let anchor = |id: &str| Mark {
1887            start: 0,
1888            end: 4,
1889            kind: MarkKind::Anchor { id: id.into() },
1890        };
1891        rt.marks = vec![anchor("x"), anchor("x")];
1892        rt.normalize();
1893        assert_eq!(rt.marks, vec![anchor("x")]);
1894        rt.marks = vec![anchor("x"), anchor("y")];
1895        rt.normalize();
1896        assert_eq!(rt.marks.len(), 2);
1897    }
1898}