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 `` 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}