Skip to main content

quillmark_content/
import.rs

1//! Markdown import (cold): `normalize → pulldown → content`.
2//!
3//! The one place the `<u>` allowlist runs: once, at the
4//! boundary (§ Codecs). Input is normalized by
5//! [`crate::normalize::normalize_markdown`] (CRLF→LF, bidi strip, HTML
6//! comment-fence repair) so the content invariants hold by construction, then
7//! parsed with `pulldown_cmark` (CommonMark + strikethrough + pipe tables) and
8//! walked into a [`Content`].
9//!
10//! ## Canonicalizations (documented, not bugs)
11//!
12//! Import maps some distinct markdown to one canonical content. All of them, in
13//! one place:
14//!
15//! - **Soft breaks → space; hard breaks → a `continues` line.** A soft break is
16//!   a space (CommonMark rendering); a hard break (two trailing spaces or `\`)
17//!   is a within-block continuation line ([`crate::model::Line::continues`]),
18//!   kept distinct from a paragraph boundary. A hard break inside a heading is a
19//!   space (ATX headings can't carry one).
20//! - **Adjacent sibling lists of the same shape merge.** Two consecutive lists
21//!   of the same kind whose items share an `ordinal` (`* a` then `+ b`, or two
22//!   ordered lists both starting at 1) are indistinguishable from one list /
23//!   one multi-paragraph item: item identity is positional `ordinal`, not a
24//!   minted list instance. Adjacent block quotes likewise merge into one.
25//! - **Empty blocks and containers keep their line.** An empty heading (`#`),
26//!   empty paragraph, empty `- ` item, or empty `>` quote each yields one empty
27//!   line so the structure survives, rather than vanishing.
28//! - **Island ids are minted sequentially** (`isl-0`, `isl-1`, …) so import is a
29//!   pure, deterministic function of its markdown. This positional scheme is
30//!   normative: ids are hash input, so a producer must derive them
31//!   deterministically and never from an ambient source (`DOCUMENT_STORAGE.md`
32//!   § Island-id determinism). Sequential ids round-trip: export drops them,
33//!   re-import re-mints the same sequence.
34//! - **Tables and images are islands.** Tables are block islands (their own
35//!   `Island` line); images are inline island slots. Both `Lossless`: pipe
36//!   tables and `![alt](url)` carry them faithfully.
37//! - **Thematic breaks are `Rule` lines.** `---`/`***`/`___` in prose (never
38//!   the root-block frontmatter alias, resolved before this layer runs) map
39//!   to a `LineKind::Rule` line carrying no text: the break is the line
40//!   itself.
41
42use crate::model::{
43    Container, Island, Line, LineKind, Loss, Mark, MarkKind, Content, ISLAND_SLOT,
44};
45use crate::island::KnownIslandType;
46use crate::normalize::normalize_markdown;
47use crate::MAX_NESTING_DEPTH;
48use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
49
50/// What `event` contributes to the alt text of an image being collected, or
51/// `None` when it contributes nothing. One rule, two accumulators: a `String`
52/// at top level and the table cell inside a table (which additionally flags the
53/// cell `degraded` and drops the URL).
54fn image_alt_text<'e>(event: &'e Event<'e>) -> Option<&'e str> {
55    match event {
56        Event::Text(t) | Event::Code(t) => Some(t),
57        Event::SoftBreak | Event::HardBreak => Some(" "),
58        _ => None,
59    }
60}
61use serde_json::json;
62
63/// Import errors: just the nesting guard (mirrors the typst backend's
64/// `ConversionError::NestingTooDeep`).
65#[derive(Debug, Clone, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum ImportError {
68    /// Container nesting exceeded [`MAX_NESTING_DEPTH`].
69    NestingTooDeep { depth: usize, max: usize },
70}
71
72impl std::fmt::Display for ImportError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            ImportError::NestingTooDeep { depth, max } => {
76                write!(f, "nesting too deep: {depth} (max {max})")
77            }
78        }
79    }
80}
81impl std::error::Error for ImportError {}
82
83/// Import markdown into a normalized, validated [`Content`] content.
84pub fn from_markdown(markdown: &str) -> Result<Content, ImportError> {
85    let normalized = normalize_markdown(markdown);
86    let mut options = Options::empty();
87    options.insert(Options::ENABLE_STRIKETHROUGH);
88    options.insert(Options::ENABLE_TABLES);
89    let fixer = MarkdownFixer::new(Parser::new_ext(&normalized, options));
90
91    let mut b = Builder::new();
92    b.run(fixer)?;
93    let mut rt = b.finish();
94    rt.normalize();
95    Ok(rt)
96}
97
98/// Import plain text (literal) into a [`Content`] content: the literal-codec
99/// sibling of [`from_markdown`]. Every character is content, never syntax:
100/// `*hi*` is four literal chars, not emphasis, and nothing is escaped. Paired
101/// with [`crate::export::to_plaintext`] as its exporter, it pins the literal
102/// fixed point `to_plaintext(from_plaintext(s)) == s` for any `s` free of `\r`,
103/// bidi controls, and the reserved island slot ([`ISLAND_SLOT`]): the same
104/// boundary cleanup [`from_markdown`] performs, so the fixed point holds for
105/// clean plaintext and the content invariants hold by construction.
106///
107/// Line structure is **derived, not stored**: a lone `\n` between two non-empty
108/// segments is a within-paragraph break ([`Line::continues`] `true`, lowered as
109/// a backend hard break); a blank line (`\n\n`) is a paragraph boundary (its own
110/// empty line resets `continues`). The text is stored verbatim, so the round
111/// trip is byte-exact and idempotent regardless of how structure is later
112/// re-derived.
113pub fn from_plaintext(s: &str) -> Content {
114    // Boundary cleanup so the content invariants hold: CRLF→LF (drop `\r`), strip
115    // bidi controls, drop the reserved island slot. Clean plaintext passes
116    // through untouched, so the literal fixed point holds for it.
117    let text: String = s
118        .chars()
119        .filter(|&c| c != '\r' && c != ISLAND_SLOT && !crate::normalize::is_bidi_char(c))
120        .collect();
121    // One line per `\n`-separated segment. `continues` marks a within-paragraph
122    // break: a lone `\n` joining two non-empty segments; any empty segment is a
123    // paragraph boundary and resets it. A single streaming pass carries the prior
124    // segment's non-emptiness, so line 0 is `false` (the flag starts `false`) and
125    // no intermediate segment vector is allocated.
126    let mut prev_nonempty = false;
127    let lines = text
128        .split('\n')
129        .map(|seg| {
130            let continues = prev_nonempty && !seg.is_empty();
131            prev_nonempty = !seg.is_empty();
132            Line {
133                kind: LineKind::Para,
134                containers: Vec::new(),
135                continues,
136            }
137        })
138        .collect();
139    Content {
140        text,
141        lines,
142        marks: Vec::new(),
143        islands: Vec::new(),
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Content builder
149// ---------------------------------------------------------------------------
150
151/// A flat inline accumulator: `text` plus `marks` over local USV offsets, with
152/// the content char-filtering baked in. One implementation serves both a prose
153/// line's inline content (embedded in the [`Builder`], which layers the
154/// line/block scaffolding on top) and a table cell's isolated content (offsets
155/// `0..cell_len`): the mark-building logic is written once, not copied per site.
156#[derive(Default)]
157struct Inline {
158    /// The accumulated text (a whole content for the [`Builder`]; one cell's text
159    /// for a table cell). USV length is tracked in [`Self::pos`].
160    text: String,
161    /// USV position = char count of [`Self::text`].
162    pos: usize,
163    marks: Vec<Mark>,
164    /// `(kind, start)` for each mark opened but not yet closed.
165    open: Vec<(MarkKind, usize)>,
166}
167
168impl Inline {
169    /// Append inline text, stripping characters the content forbids: `\r` and a
170    /// stray [`ISLAND_SLOT`] are dropped; a stray `\n` becomes a space (inline
171    /// text carries no line boundary, real ones go through [`Self::push_raw`]).
172    ///
173    /// A literal [`ISLAND_SLOT`] (U+FFFC) in source markdown is dropped
174    /// *silently and by design*: the slot char is the reserved island sentinel,
175    /// so admitting a bare one would break the slot-count invariant. Such a char
176    /// in prose is a paste/render artifact, never authored content, so its loss
177    /// carries no signal: this is a fixed point, not lossy round-tripping.
178    fn push_text(&mut self, s: &str) {
179        for c in s.chars() {
180            let c = match c {
181                '\r' => continue,
182                ISLAND_SLOT => continue,
183                '\n' => ' ',
184                other => other,
185            };
186            self.text.push(c);
187            self.pos += 1;
188        }
189    }
190
191    /// Append one char verbatim (a line-boundary `\n`, an island slot), bypassing
192    /// the [`Self::push_text`] filtering.
193    fn push_raw(&mut self, c: char) {
194        self.text.push(c);
195        self.pos += 1;
196    }
197
198    /// Open a mark at the current position.
199    fn open_mark(&mut self, kind: MarkKind) {
200        self.open.push((kind, self.pos));
201    }
202
203    /// Close the innermost open mark (pulldown nests them well).
204    fn close_mark(&mut self) {
205        if let Some((kind, start)) = self.open.pop() {
206            self.marks.push(Mark {
207                start,
208                end: self.pos,
209                kind,
210            });
211        }
212    }
213
214    /// Append inline code text and record its [`MarkKind::Code`] mark over it.
215    fn push_code(&mut self, s: &str) {
216        let start = self.pos;
217        self.push_text(s);
218        self.marks.push(Mark {
219            start,
220            end: self.pos,
221            kind: MarkKind::Code,
222        });
223    }
224}
225
226struct Builder {
227    /// The content text + marks; the [`Builder`] adds line/block structure around
228    /// it (a `\n` boundary is [`Inline::push_raw`], inline content is the mark
229    /// machinery). A table cell reuses the same [`Inline`] in isolation.
230    inline: Inline,
231    lines: Vec<Line>,
232    cur: Option<Line>, // the line currently open (kind + containers fixed at open)
233    /// A block start records `(kind, continues)` the next inline content should
234    /// open a fresh line with. Set at Paragraph/Heading/Item (tight lists emit no
235    /// Paragraph wrapper, so Item must force a line) with `continues = false`; a
236    /// hard break sets `continues = true`. Cleared when a block that owns its own
237    /// lines (List/Quote/CodeBlock/Table) takes over.
238    pending: Option<(LineKind, bool)>,
239    islands: Vec<Island>,
240    island_seq: usize,
241    containers: Vec<Container>,
242    /// Parallel to `containers`: the [`Self::emitted`] count when each container
243    /// opened, so a container that closes having emitted no line (an empty `>`
244    /// quote, an empty `- ` item) can still get one.
245    container_marks: Vec<usize>,
246    list_stack: Vec<ListInfo>,
247    // code block
248    code_lang: Option<String>,
249    in_code: bool,
250    code_opened: bool, // whether the current code block has opened its first line
251    // image collection
252    image_depth: usize,
253    image_url: String,
254    image_alt: String,
255    // table collection
256    table: Option<TableAcc>,
257}
258
259#[derive(Clone)]
260struct ListInfo {
261    ordered: bool,
262    start: u64,
263    /// 0-based index of the next item: becomes the item's `ordinal`.
264    count: u64,
265}
266
267struct TableAcc {
268    aligns: Vec<&'static str>,
269    /// Cells as canonical `{text, marks}` JSON (via `serial::cell_to_value`), so
270    /// nothing downstream re-parses markdown to render a formatted cell.
271    header: Vec<serde_json::Value>,
272    rows: Vec<Vec<serde_json::Value>>,
273    cur_row: Vec<serde_json::Value>,
274    in_head: bool,
275    /// The cell currently open (between `Tag::TableCell` start/end), building its
276    /// inline text + marks with the same [`Inline`] machinery prose uses.
277    cell: Option<Inline>,
278    /// Open-image nesting inside the current cell. GFM permits inline images in
279    /// cells, but a cell has no island slot to carry one; while `> 0` the image's
280    /// alt flows into the cell as plain text (the degraded projection) and its
281    /// url is dropped. Mirrors the top-level `image_depth` interception.
282    img_depth: usize,
283    /// Whether any cell dropped an image's url, the island is then minted
284    /// [`Loss::DEGRADED`], not `LOSSLESS`: the markdown/Typst projection carries
285    /// the alt text but not the image.
286    degraded: bool,
287}
288
289fn align_str(a: &pulldown_cmark::Alignment) -> &'static str {
290    match a {
291        pulldown_cmark::Alignment::None => "none",
292        pulldown_cmark::Alignment::Left => "left",
293        pulldown_cmark::Alignment::Center => "center",
294        pulldown_cmark::Alignment::Right => "right",
295    }
296}
297
298impl Builder {
299    fn new() -> Self {
300        Builder {
301            inline: Inline::default(),
302            lines: Vec::new(),
303            cur: None,
304            pending: None,
305            islands: Vec::new(),
306            island_seq: 0,
307            containers: Vec::new(),
308            container_marks: Vec::new(),
309            list_stack: Vec::new(),
310            code_lang: None,
311            in_code: false,
312            code_opened: false,
313            image_depth: 0,
314            image_url: String::new(),
315            image_alt: String::new(),
316            table: None,
317        }
318    }
319
320    /// Open a fresh line with `kind` and the current container path. The first
321    /// open sets the line directly; each later one first closes the previous
322    /// line with a single `\n` boundary, so `lines.len()` always equals the
323    /// `\n`-segment count.
324    fn open_line(&mut self, kind: LineKind, continues: bool) {
325        // The first line (no line yet open) can never continue anything.
326        let continues = continues && self.cur.is_some();
327        if let Some(prev) = self.cur.take() {
328            self.inline.push_raw('\n');
329            self.lines.push(prev);
330        }
331        self.cur = Some(Line {
332            kind,
333            containers: self.containers.clone(),
334            continues,
335        });
336    }
337
338    /// Open a fresh line for a `pending_kind` set at the last block start, or
339    /// (defensively) a `default` line if inline content arrives with none
340    /// pending and no line open. A no-op when a line is already open and no new
341    /// one is pending: inline content flows onto the current line.
342    fn ensure_open(&mut self, default: LineKind) {
343        if let Some((k, cont)) = self.pending.take() {
344            self.open_line(k, cont);
345        } else if self.cur.is_none() {
346            self.open_line(default, false);
347        }
348    }
349
350    /// Append inline text to the current line, stripping any characters the
351    /// content invariants forbid (stray `\r`, stray island slots; stray `\n`
352    /// becomes a space: inline text should carry none).
353    fn push_inline(&mut self, s: &str) {
354        self.ensure_open(LineKind::Para);
355        self.inline.push_text(s);
356    }
357
358    /// Lines emitted so far, counting the line currently open. A container that
359    /// closes with this unchanged from when it opened produced nothing.
360    fn emitted(&self) -> usize {
361        self.lines.len() + usize::from(self.cur.is_some())
362    }
363
364    /// Open a line for a block that ended with no inline content (an empty
365    /// heading `#`, an empty paragraph): otherwise the block, and any content
366    /// model it carries, is silently lost.
367    fn flush_empty_block(&mut self) {
368        if let Some((k, cont)) = self.pending.take() {
369            self.open_line(k, cont);
370        }
371    }
372
373    /// Close a container: if it emitted no line, give it one empty `Para` line
374    /// (an empty `- ` item, an empty `>` quote) so the structure survives; then
375    /// pop it. `mark` is the [`Self::emitted`] snapshot from when it opened.
376    fn close_container(&mut self, mark: usize) {
377        if self.emitted() == mark {
378            self.pending = None;
379            self.open_line(LineKind::Para, false);
380        }
381        self.containers.pop();
382    }
383
384    fn open_mark(&mut self, kind: MarkKind) {
385        // Resolve any armed line first, so a mark that begins a block records
386        // the position *after* the block's line boundary: not the `\n` before
387        // it. Without this the mark swallows the separator and equal content
388        // from an editor vs from import serializes to different canonical bytes.
389        self.ensure_open(LineKind::Para);
390        self.inline.open_mark(kind);
391    }
392
393    fn close_mark(&mut self) {
394        // Well-nested by pulldown: close the innermost open mark.
395        self.inline.close_mark();
396    }
397
398    /// Mint an island of a *known* type: the importer can only produce the
399    /// closed set, so an unknown type can enter the system through storage
400    /// deserialization but never through import. The `isl-{seq}` id is the
401    /// normative deterministic scheme (`DOCUMENT_STORAGE.md` § Island-id
402    /// determinism); minting by position keeps import a pure function.
403    fn mint_island(&mut self, kind: KnownIslandType, props: serde_json::Value, loss: Loss) {
404        let id = format!("isl-{}", self.island_seq);
405        self.island_seq += 1;
406        self.islands.push(Island {
407            id,
408            island_type: kind.as_str().to_string(),
409            props,
410            loss,
411        });
412    }
413
414    fn check_depth(&self) -> Result<(), ImportError> {
415        // Container path plus open marks approximates the structural depth the
416        // typst backend caps; bound it identically for parity.
417        let depth = self.containers.len() + self.inline.open.len();
418        if depth > MAX_NESTING_DEPTH {
419            return Err(ImportError::NestingTooDeep {
420                depth,
421                max: MAX_NESTING_DEPTH,
422            });
423        }
424        Ok(())
425    }
426
427    fn run<'a, I>(&mut self, iter: I) -> Result<(), ImportError>
428    where
429        I: Iterator<Item = (Event<'a>, bool)>,
430    {
431        for (event, underline) in iter {
432            // Image alt collection intercepts everything until the image closes.
433            if self.image_depth > 0 {
434                match &event {
435                    Event::Start(Tag::Image { .. }) => self.image_depth += 1,
436                    Event::End(TagEnd::Image) => {
437                        self.image_depth -= 1;
438                        if self.image_depth == 0 {
439                            self.emit_image();
440                        }
441                    }
442                    other => {
443                        if let Some(s) = image_alt_text(other) {
444                            self.image_alt.push_str(s);
445                        }
446                    }
447                }
448                continue;
449            }
450
451            // Table collection routes both structural events (head/row/cell) and
452            // a cell's inline content (text/marks) to the accumulator, so each
453            // cell is stored as canonical `{text, marks}`: no markdown re-parse
454            // downstream.
455            if self.table.is_some() {
456                self.table_event(&event, underline);
457                if matches!(event, Event::End(TagEnd::Table)) {
458                    self.emit_table();
459                }
460                continue;
461            }
462
463            match event {
464                Event::Start(tag) => self.start_tag(tag, underline)?,
465                Event::End(tag) => self.end_tag(tag),
466                Event::Text(t) => {
467                    if self.in_code {
468                        self.push_code_content(&t);
469                    } else {
470                        self.push_inline(&t);
471                    }
472                }
473                Event::Code(t) => {
474                    self.ensure_open(LineKind::Para);
475                    self.inline.push_code(&t);
476                }
477                Event::Rule => self.open_line(LineKind::Rule, false),
478                Event::SoftBreak => self.push_inline(" "),
479                Event::HardBreak => {
480                    match self.cur.as_ref().map(|l| &l.kind) {
481                        // ATX headings can't carry a hard break in markdown, so
482                        // one inside a heading canonicalizes to a space (a
483                        // documented, representable choice).
484                        Some(LineKind::Heading { .. }) => self.push_inline(" "),
485                        // Elsewhere: a within-block line break: arm a pending
486                        // continuation line (same kind, continues = true) so it
487                        // stays one block and export re-emits a hard break, not a
488                        // paragraph split.
489                        _ => {
490                            let kind = self
491                                .cur
492                                .as_ref()
493                                .map(|l| l.kind.clone())
494                                .unwrap_or(LineKind::Para);
495                            self.pending = Some((kind, true));
496                        }
497                    }
498                }
499                // Html/InlineHtml already stripped or rewritten by the fixer;
500                // math/footnotes/etc. produce no content.
501                _ => {}
502            }
503        }
504        Ok(())
505    }
506
507    fn start_tag<'a>(&mut self, tag: Tag<'a>, underline: bool) -> Result<(), ImportError> {
508        match tag {
509            // Block starts arm a pending line (new block, continues = false);
510            // the next inline content opens it.
511            Tag::Paragraph => self.pending = Some((LineKind::Para, false)),
512            Tag::Heading { level, .. } => {
513                self.pending = Some((
514                    LineKind::Heading {
515                        level: heading_level(level),
516                    },
517                    false,
518                ))
519            }
520            Tag::CodeBlock(kind) => {
521                self.pending = None; // code opens its own lines
522                self.in_code = true;
523                self.code_lang = match kind {
524                    pulldown_cmark::CodeBlockKind::Fenced(lang) => {
525                        let l = sanitize_lang(&lang);
526                        if l.is_empty() {
527                            None
528                        } else {
529                            Some(l)
530                        }
531                    }
532                    pulldown_cmark::CodeBlockKind::Indented => None,
533                };
534                // First code line opens on the first content chunk; nothing to
535                // open yet (a code block with no content still yields one line,
536                // handled in push_code_content / end).
537                self.code_opened = false;
538            }
539            Tag::List(start) => {
540                self.pending = None; // nested list content sets its own
541                self.list_stack.push(ListInfo {
542                    ordered: start.is_some(),
543                    start: start.unwrap_or(1),
544                    count: 0,
545                });
546            }
547            Tag::Item => {
548                // Tight-list items carry no Paragraph wrapper, so the item start
549                // is what forces a new line for the item's first inline content.
550                self.pending = Some((LineKind::Para, false));
551                self.container_marks.push(self.emitted());
552                let container = match self.list_stack.last_mut() {
553                    Some(info) => {
554                        let ordinal = info.count;
555                        info.count += 1;
556                        Container::ListItem {
557                            ordered: info.ordered,
558                            start: info.start,
559                            ordinal,
560                        }
561                    }
562                    None => Container::ListItem {
563                        ordered: false,
564                        start: 1,
565                        ordinal: 0,
566                    },
567                };
568                self.containers.push(container);
569                self.check_depth()?;
570            }
571            Tag::BlockQuote(_) => {
572                self.pending = None; // quote content sets its own
573                self.container_marks.push(self.emitted());
574                self.containers.push(Container::Quote);
575                self.check_depth()?;
576            }
577            Tag::Table(aligns) => {
578                self.pending = None;
579                self.open_line(LineKind::Island, false);
580                self.inline.push_raw(ISLAND_SLOT);
581                self.table = Some(TableAcc {
582                    aligns: aligns.iter().map(align_str).collect(),
583                    header: Vec::new(),
584                    rows: Vec::new(),
585                    cur_row: Vec::new(),
586                    in_head: false,
587                    cell: None,
588                    img_depth: 0,
589                    degraded: false,
590                });
591            }
592            Tag::Emphasis => {
593                self.open_mark(MarkKind::Emph);
594                self.check_depth()?;
595            }
596            Tag::Strong => {
597                let kind = strong_kind(underline);
598                self.open_mark(kind);
599                self.check_depth()?;
600            }
601            Tag::Strikethrough => {
602                self.open_mark(MarkKind::Strike);
603                self.check_depth()?;
604            }
605            Tag::Link { dest_url, .. } => {
606                self.open_mark(MarkKind::Link {
607                    url: dest_url.to_string(),
608                });
609                self.check_depth()?;
610            }
611            Tag::Image { dest_url, .. } => {
612                self.image_url = dest_url.to_string();
613                self.image_alt.clear();
614                self.image_depth = 1;
615            }
616            _ => {}
617        }
618        Ok(())
619    }
620
621    fn end_tag(&mut self, tag: TagEnd) {
622        match tag {
623            TagEnd::CodeBlock => {
624                if !self.code_opened {
625                    // Empty code block: one empty Code line.
626                    let lang = self.code_lang.take();
627                    self.open_line(LineKind::Code { lang }, false);
628                }
629                self.in_code = false;
630                self.code_lang = None;
631            }
632            TagEnd::List(_) => {
633                self.list_stack.pop();
634            }
635            TagEnd::Item => {
636                let mark = self.container_marks.pop().unwrap_or(0);
637                self.close_container(mark);
638            }
639            TagEnd::BlockQuote(_) => {
640                let mark = self.container_marks.pop().unwrap_or(0);
641                self.close_container(mark);
642            }
643            TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough | TagEnd::Link => {
644                self.close_mark()
645            }
646            // A block that produced no inline content still gets its line.
647            TagEnd::Heading(_) | TagEnd::Paragraph => self.flush_empty_block(),
648            _ => {}
649        }
650    }
651
652    fn push_code_content(&mut self, content: &str) {
653        // pulldown appends a trailing newline as the last line's terminator, not
654        // content; drop exactly one so an N-line block yields N lines.
655        let content = content.strip_suffix('\n').unwrap_or(content);
656        for seg in content.split('\n') {
657            // First line of the block starts it (continues = false); every later
658            // line is a within-block continuation, so the fence stays one block.
659            let continues = self.code_opened;
660            self.open_line(
661                LineKind::Code {
662                    lang: self.code_lang.clone(),
663                },
664                continues,
665            );
666            self.code_opened = true;
667            // Code text is literal; still enforce content invariants.
668            self.push_code_line(seg);
669        }
670    }
671
672    fn push_code_line(&mut self, seg: &str) {
673        for c in seg.chars() {
674            match c {
675                '\r' | '\n' => continue,
676                ISLAND_SLOT => continue,
677                other => self.inline.push_raw(other),
678            }
679        }
680    }
681
682    // ---- table ----
683
684    /// The open table cell's inline accumulator, if one is open.
685    fn cell_mut(&mut self) -> Option<&mut Inline> {
686        self.table.as_mut()?.cell.as_mut()
687    }
688
689    /// Route one table event: structural events (head/row/cell boundaries) shape
690    /// the accumulator; inline events (text/code/marks) build the open cell with
691    /// the SAME [`Inline`] machinery prose uses, a cell is flat inline (no lines,
692    /// no nested islands), so its marks are USV offsets into its own text.
693    fn table_event(&mut self, event: &Event, underline: bool) {
694        // An image open inside the current cell intercepts everything until it
695        // closes: the alt text lands in the cell as plain text (marks flattened,
696        // like the top-level image path), the url is dropped, and the island is
697        // flagged degraded. A cell has no island slot to carry a real image.
698        if self.table.as_ref().is_some_and(|a| a.img_depth > 0) {
699            match event {
700                Event::Start(Tag::Image { .. }) => {
701                    if let Some(a) = self.table.as_mut() {
702                        a.img_depth += 1;
703                    }
704                }
705                Event::End(TagEnd::Image) => {
706                    if let Some(a) = self.table.as_mut() {
707                        a.img_depth -= 1;
708                    }
709                }
710                other => {
711                    if let Some(s) = image_alt_text(other) {
712                        if let Some(c) = self.cell_mut() {
713                            c.push_text(s);
714                        }
715                    }
716                }
717            }
718            return;
719        }
720        match event {
721            Event::Start(Tag::Image { .. }) => {
722                if let Some(a) = self.table.as_mut() {
723                    a.img_depth += 1;
724                    a.degraded = true;
725                }
726            }
727            Event::Start(Tag::TableHead) => {
728                if let Some(a) = self.table.as_mut() {
729                    a.in_head = true;
730                }
731            }
732            Event::End(TagEnd::TableHead) => {
733                if let Some(a) = self.table.as_mut() {
734                    a.header = std::mem::take(&mut a.cur_row);
735                    a.in_head = false;
736                }
737            }
738            Event::Start(Tag::TableRow) => {
739                if let Some(a) = self.table.as_mut() {
740                    a.cur_row.clear();
741                }
742            }
743            Event::End(TagEnd::TableRow) => {
744                if let Some(a) = self.table.as_mut() {
745                    if !a.in_head {
746                        let row = std::mem::take(&mut a.cur_row);
747                        a.rows.push(row);
748                    }
749                }
750            }
751            Event::Start(Tag::TableCell) => {
752                if let Some(a) = self.table.as_mut() {
753                    a.cell = Some(Inline::default());
754                }
755            }
756            Event::End(TagEnd::TableCell) => {
757                if let Some(a) = self.table.as_mut() {
758                    if let Some(mut cell) = a.cell.take() {
759                        // Close any marks pulldown left open (malformed input).
760                        while !cell.open.is_empty() {
761                            cell.close_mark();
762                        }
763                        a.cur_row
764                            .push(crate::serial::cell_to_value(&cell.text, &cell.marks));
765                    }
766                }
767            }
768            // Inline content of the open cell (pulldown already trimmed the cell's
769            // surrounding whitespace; the fixer already stripped non-`<u>` HTML).
770            // A soft/hard break in a single-line cell is a space.
771            Event::Text(t) => {
772                if let Some(c) = self.cell_mut() {
773                    c.push_text(t);
774                }
775            }
776            Event::Code(t) => {
777                if let Some(c) = self.cell_mut() {
778                    c.push_code(t);
779                }
780            }
781            Event::SoftBreak | Event::HardBreak => {
782                if let Some(c) = self.cell_mut() {
783                    c.push_text(" ");
784                }
785            }
786            Event::Start(Tag::Emphasis) => {
787                if let Some(c) = self.cell_mut() {
788                    c.open_mark(MarkKind::Emph);
789                }
790            }
791            Event::Start(Tag::Strong) => {
792                let kind = strong_kind(underline);
793                if let Some(c) = self.cell_mut() {
794                    c.open_mark(kind);
795                }
796            }
797            Event::Start(Tag::Strikethrough) => {
798                if let Some(c) = self.cell_mut() {
799                    c.open_mark(MarkKind::Strike);
800                }
801            }
802            Event::Start(Tag::Link { dest_url, .. }) => {
803                let url = dest_url.to_string();
804                if let Some(c) = self.cell_mut() {
805                    c.open_mark(MarkKind::Link { url });
806                }
807            }
808            Event::End(TagEnd::Emphasis)
809            | Event::End(TagEnd::Strong)
810            | Event::End(TagEnd::Strikethrough)
811            | Event::End(TagEnd::Link) => {
812                if let Some(c) = self.cell_mut() {
813                    c.close_mark();
814                }
815            }
816            _ => {}
817        }
818    }
819
820    fn emit_table(&mut self) {
821        if let Some(acc) = self.table.take() {
822            let props = json!({
823                "aligns": acc.aligns,
824                "header": acc.header,
825                "rows": acc.rows,
826            });
827            // Degraded when a cell dropped an inline image's url: the projection
828            // then carries the alt text but not the image (not a fixed point);
829            // otherwise the type's ceiling. Recorded, not acted on: `Loss`
830            // describes fidelity for a consumer to surface; no
831            // projection branches on it.
832            let loss = if acc.degraded {
833                Loss::DEGRADED
834            } else {
835                KnownIslandType::Table.default_loss()
836            };
837            self.mint_island(KnownIslandType::Table, props, loss);
838        }
839    }
840
841    fn emit_image(&mut self) {
842        self.ensure_open(LineKind::Para);
843        self.inline.push_raw(ISLAND_SLOT);
844        let props = json!({
845            "url": self.image_url,
846            "alt": self.image_alt.trim(),
847        });
848        self.mint_island(KnownIslandType::Image, props, KnownIslandType::Image.default_loss());
849    }
850
851    fn finish(mut self) -> Content {
852        if let Some(last) = self.cur.take() {
853            self.lines.push(last);
854        }
855        if self.lines.is_empty() {
856            // Empty document: one empty Para line.
857            self.lines.push(Line {
858                kind: LineKind::Para,
859                containers: Vec::new(),
860                continues: false,
861            });
862        }
863        // Close any marks left open (unterminated `<u>`, malformed input).
864        while !self.inline.open.is_empty() {
865            self.close_mark();
866        }
867        Content {
868            text: self.inline.text,
869            lines: self.lines,
870            marks: self.inline.marks,
871            islands: self.islands,
872        }
873    }
874}
875
876fn heading_level(level: pulldown_cmark::HeadingLevel) -> u8 {
877    use pulldown_cmark::HeadingLevel::*;
878    match level {
879        H1 => 1,
880        H2 => 2,
881        H3 => 3,
882        H4 => 4,
883        H5 => 5,
884        H6 => 6,
885    }
886}
887
888/// Sanitize a code-block info string to a language identifier (parity with the
889/// typst backend's `sanitize_lang_tag`).
890fn sanitize_lang(lang: &str) -> String {
891    lang.chars()
892        .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
893        .collect()
894}
895
896// ---------------------------------------------------------------------------
897// MarkdownFixer: the raw-HTML filter between pulldown and the builder.
898//
899// One job: allowlist `<u>…</u>` as underline (rewritten to Strong start/end,
900// the classification riding the rewritten event) and drop every other raw HTML
901// event. Delimiter arithmetic is pulldown's: a fixer that
902// re-segments `***` runs can only disagree with CommonMark, and disagreeing
903// means deleting an asterisk the author typed (`***a**` is a literal `*` then
904// strong `a`; `***bold italic***` parses natively).
905// ---------------------------------------------------------------------------
906
907fn is_u_open_tag(html: &str) -> bool {
908    let s = html.trim();
909    if s.starts_with('<') && s.ends_with('>') {
910        s[1..s.len() - 1].trim().eq_ignore_ascii_case("u")
911    } else {
912        false
913    }
914}
915
916fn is_u_close_tag(html: &str) -> bool {
917    let s = html.trim();
918    if s.starts_with("</") && s.ends_with('>') {
919        s[2..s.len() - 1].trim().eq_ignore_ascii_case("u")
920    } else {
921        false
922    }
923}
924
925/// [`MarkKind::Underline`] when the fixer rewrote a `<u>` open into this
926/// `Tag::Strong`, else [`MarkKind::Strong`]: the classification rides the
927/// event, so no site re-sniffs source bytes.
928fn strong_kind(underline: bool) -> MarkKind {
929    if underline {
930        MarkKind::Underline
931    } else {
932        MarkKind::Strong
933    }
934}
935
936struct MarkdownFixer<'a, I: Iterator<Item = Event<'a>>> {
937    inner: I,
938    _marker: std::marker::PhantomData<&'a ()>,
939}
940
941impl<'a, I> MarkdownFixer<'a, I>
942where
943    I: Iterator<Item = Event<'a>>,
944{
945    fn new(inner: I) -> Self {
946        Self {
947            inner,
948            _marker: std::marker::PhantomData,
949        }
950    }
951}
952
953impl<'a, I> Iterator for MarkdownFixer<'a, I>
954where
955    I: Iterator<Item = Event<'a>>,
956{
957    /// The event, plus whether a `Tag::Strong` start was rewritten from `<u>`
958    /// (always `false` for every other event).
959    type Item = (Event<'a>, bool);
960
961    fn next(&mut self) -> Option<Self::Item> {
962        loop {
963            return Some(match self.inner.next()? {
964                Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_open_tag(html) => {
965                    (Event::Start(Tag::Strong), true)
966                }
967                Event::InlineHtml(ref html) | Event::Html(ref html) if is_u_close_tag(html) => {
968                    (Event::End(TagEnd::Strong), false)
969                }
970                Event::Html(_) | Event::InlineHtml(_) => continue,
971                other => (other, false),
972            });
973        }
974    }
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980    use crate::model::LineKind;
981
982    fn imp(md: &str) -> Content {
983        let rt = from_markdown(md).unwrap();
984        assert_eq!(rt.validate(), Ok(()), "invariants for {md:?}");
985        rt
986    }
987
988    fn imp_plain(s: &str) -> Content {
989        let rt = from_plaintext(s);
990        assert_eq!(rt.validate(), Ok(()), "invariants for {s:?}");
991        rt
992    }
993
994    /// Plaintext is literal: markdown delimiters are content, not syntax, and
995    /// nothing is escaped or marked. The content is mark- and island-free.
996    #[test]
997    fn plaintext_is_literal_and_plain() {
998        let rt = imp_plain("a *star* and _under_ #hash");
999        assert_eq!(rt.text, "a *star* and _under_ #hash");
1000        assert!(rt.marks.is_empty());
1001        assert!(rt.islands.is_empty());
1002        assert!(rt.is_plain());
1003        assert!(rt.is_inline(), "one line with no formatting is also inline");
1004    }
1005
1006    /// The literal fixed point: `to_plaintext(from_plaintext(s)) == s` for clean
1007    /// text, and re-import is idempotent.
1008    #[test]
1009    fn plaintext_round_trip_is_verbatim_and_idempotent() {
1010        for s in ["", "one line", "a\nb", "a\n\nb", "trailing\n", "*not bold*"] {
1011            let rt = imp_plain(s);
1012            assert_eq!(crate::export::to_plaintext(&rt), s, "verbatim for {s:?}");
1013            let rt2 = from_plaintext(&crate::export::to_plaintext(&rt));
1014            assert_eq!(rt2.text, rt.text, "idempotent for {s:?}");
1015            assert_eq!(rt2.lines, rt.lines, "idempotent structure for {s:?}");
1016        }
1017    }
1018
1019    /// Lone `\n` between non-empty segments is a within-paragraph break
1020    /// (`continues: true`); a blank line resets it to a paragraph boundary.
1021    #[test]
1022    fn plaintext_derives_continues_from_line_structure() {
1023        let rt = imp_plain("a\nb");
1024        assert_eq!(rt.lines.len(), 2);
1025        assert!(!rt.lines[0].continues);
1026        assert!(rt.lines[1].continues, "lone \\n is a within-paragraph break");
1027
1028        let rt = imp_plain("a\n\nb");
1029        assert_eq!(rt.lines.len(), 3);
1030        assert!(!rt.lines[0].continues);
1031        assert!(!rt.lines[1].continues, "the blank line is a paragraph boundary");
1032        assert!(!rt.lines[2].continues, "text after a blank line starts a new block");
1033    }
1034
1035    /// Boundary cleanup keeps the content invariants: CRLF collapses to LF, bidi
1036    /// controls and the reserved island slot are dropped. Clean text is
1037    /// unaffected, so the fixed point still holds for it.
1038    #[test]
1039    fn plaintext_strips_invariant_breakers() {
1040        let rt = imp_plain("a\r\nb");
1041        assert_eq!(rt.text, "a\nb", "CRLF collapses to LF");
1042        let rt = imp_plain(&format!("a{ISLAND_SLOT}b"));
1043        assert_eq!(rt.text, "ab", "the reserved island slot is dropped");
1044        assert_eq!(rt.islands.len(), 0);
1045    }
1046
1047    #[test]
1048    fn plain_paragraph() {
1049        let rt = imp("Hello world");
1050        assert_eq!(rt.text, "Hello world");
1051        assert_eq!(rt.lines.len(), 1);
1052        assert_eq!(rt.lines[0].kind, LineKind::Para);
1053        assert!(rt.marks.is_empty());
1054    }
1055
1056    #[test]
1057    fn bold_and_italic_marks() {
1058        let rt = imp("a **b** _c_");
1059        assert_eq!(rt.text, "a b c");
1060        // "b" at 2..3 strong, "c" at 4..5 emph
1061        assert!(rt.marks.contains(&Mark {
1062            start: 2,
1063            end: 3,
1064            kind: MarkKind::Strong
1065        }));
1066        assert!(rt.marks.contains(&Mark {
1067            start: 4,
1068            end: 5,
1069            kind: MarkKind::Emph
1070        }));
1071    }
1072
1073    #[test]
1074    fn underline_from_u_tag() {
1075        let rt = imp("x <u>y</u> z");
1076        assert_eq!(rt.text, "x y z");
1077        assert!(rt
1078            .marks
1079            .iter()
1080            .any(|m| m.kind == MarkKind::Underline && m.start == 2 && m.end == 3));
1081    }
1082
1083    #[test]
1084    fn other_html_stripped() {
1085        let rt = imp("a <span>b</span> c");
1086        assert_eq!(rt.text, "a b c");
1087    }
1088
1089    /// An asterisk run the author typed reaches the content.
1090    /// `***a**` is a literal `*` followed by strong `a` (CommonMark's rule of
1091    /// three: the closing run matches two of the three), and every shape here
1092    /// keeps its stars, a fixer re-segmenting the run deleted one.
1093    #[test]
1094    fn odd_asterisk_runs_keep_their_literal_star() {
1095        for (src, text) in [
1096            ("***a**", "*a"),
1097            ("***aa**", "*aa"),
1098            ("****a**", "**a"),
1099            ("a***a**", "a*a"),
1100        ] {
1101            assert_eq!(imp(src).text, text, "literal star dropped from {src:?}");
1102        }
1103        // The shape the fixup read as its reason for existing: pulldown nests
1104        // strong+emph natively, with no star left over.
1105        let rt = imp("***bold italic***");
1106        assert_eq!(rt.text, "bold italic");
1107        assert!(rt.marks.iter().any(|m| m.kind == MarkKind::Strong));
1108        assert!(rt.marks.iter().any(|m| m.kind == MarkKind::Emph));
1109    }
1110
1111    /// A `<u>`-lookalike must not be read as underline. The fixer's single
1112    /// `is_u_open_tag` classifier rejects `<ul>` (inner != "u"), so it is
1113    /// stripped like any other HTML: no underline, no strong. Regression for
1114    /// the old split where a separate 2-byte `<u` prefix peek would have
1115    /// mis-classified it had the fixer ever converted it.
1116    #[test]
1117    fn ul_lookalike_is_not_underline() {
1118        let rt = imp("x <ul>y</ul> z");
1119        assert_eq!(rt.text, "x y z");
1120        assert!(rt
1121            .marks
1122            .iter()
1123            .all(|m| m.kind != MarkKind::Underline && m.kind != MarkKind::Strong));
1124    }
1125
1126    #[test]
1127    fn two_paragraphs_two_lines() {
1128        let rt = imp("one\n\ntwo");
1129        assert_eq!(rt.text, "one\ntwo");
1130        assert_eq!(rt.lines.len(), 2);
1131        assert!(rt.lines.iter().all(|l| l.kind == LineKind::Para));
1132    }
1133
1134    #[test]
1135    fn heading_line_kind() {
1136        let rt = imp("## Title");
1137        assert_eq!(rt.text, "Title");
1138        assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
1139    }
1140
1141    #[test]
1142    fn inline_code_mark() {
1143        let rt = imp("run `cargo test` now");
1144        assert_eq!(rt.text, "run cargo test now");
1145        assert!(rt
1146            .marks
1147            .iter()
1148            .any(|m| m.kind == MarkKind::Code && m.start == 4 && m.end == 14));
1149    }
1150
1151    #[test]
1152    fn code_block_lines() {
1153        let rt = imp("```rust\nfn a() {}\nfn b() {}\n```");
1154        assert_eq!(rt.text, "fn a() {}\nfn b() {}");
1155        assert_eq!(rt.lines.len(), 2);
1156        assert!(rt.lines.iter().all(|l| l.kind
1157            == LineKind::Code {
1158                lang: Some("rust".into())
1159            }));
1160    }
1161
1162    #[test]
1163    fn bullet_list_containers() {
1164        let rt = imp("- a\n- b");
1165        assert_eq!(rt.text, "a\nb");
1166        assert_eq!(rt.lines.len(), 2);
1167        // Two items: same list (ordered=false, start=1), distinct ordinals.
1168        assert_eq!(
1169            rt.lines[0].containers,
1170            vec![Container::ListItem {
1171                ordered: false,
1172                start: 1,
1173                ordinal: 0
1174            }]
1175        );
1176        assert_eq!(
1177            rt.lines[1].containers,
1178            vec![Container::ListItem {
1179                ordered: false,
1180                start: 1,
1181                ordinal: 1
1182            }]
1183        );
1184    }
1185
1186    #[test]
1187    fn ordered_list_custom_start() {
1188        let rt = imp("3. a\n4. b");
1189        assert_eq!(
1190            rt.lines[0].containers,
1191            vec![Container::ListItem {
1192                ordered: true,
1193                start: 3,
1194                ordinal: 0
1195            }]
1196        );
1197        assert_eq!(
1198            rt.lines[1].containers,
1199            vec![Container::ListItem {
1200                ordered: true,
1201                start: 3,
1202                ordinal: 1
1203            }]
1204        );
1205    }
1206
1207    #[test]
1208    fn multi_paragraph_list_item_shares_container() {
1209        // One item with two paragraphs -> two Para lines sharing one ListItem.
1210        let rt = imp("- first\n\n  second");
1211        assert_eq!(rt.lines.len(), 2);
1212        assert_eq!(rt.lines[0].containers, rt.lines[1].containers);
1213        assert_eq!(
1214            rt.lines[0].containers,
1215            vec![Container::ListItem {
1216                ordered: false,
1217                start: 1,
1218                ordinal: 0
1219            }]
1220        );
1221    }
1222
1223    #[test]
1224    fn blockquote_container() {
1225        let rt = imp("> quoted");
1226        assert_eq!(rt.text, "quoted");
1227        assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
1228    }
1229
1230    #[test]
1231    fn thematic_break_is_rule_line() {
1232        for src in ["---", "***", "___"] {
1233            let md = format!("one\n\n{src}\n\ntwo");
1234            let rt = imp(&md);
1235            assert_eq!(rt.lines.len(), 3, "source: {src}");
1236            assert_eq!(rt.lines[0].kind, LineKind::Para);
1237            assert_eq!(rt.lines[1].kind, LineKind::Rule, "source: {src}");
1238            assert_eq!(rt.lines[2].kind, LineKind::Para);
1239            // The rule line carries no text of its own.
1240            assert_eq!(rt.text, "one\n\ntwo");
1241        }
1242    }
1243
1244    #[test]
1245    fn table_is_block_island() {
1246        let rt = imp("| a | b |\n|---|---|\n| 1 | 2 |");
1247        assert_eq!(rt.text, "\u{FFFC}");
1248        assert_eq!(rt.lines[0].kind, LineKind::Island);
1249        assert_eq!(rt.islands.len(), 1);
1250        assert_eq!(rt.islands[0].island_type, "table");
1251        assert_eq!(rt.islands[0].loss, Loss::LOSSLESS);
1252    }
1253
1254    /// The cell lane's own `<u>` classification. A cell reuses the prose mark
1255    /// machinery through a second `Tag::Strong` site, so the two must agree:
1256    /// `<u>` opens [`MarkKind::Underline`], `**` opens [`MarkKind::Strong`],
1257    /// in the same cell.
1258    #[test]
1259    fn underline_from_u_tag_in_table_cell() {
1260        let rt = imp("| h |\n|---|\n| <u>a</u> **b** |");
1261        let cells = crate::serial::table_cells(&rt.islands[0].props);
1262        let (text, marks) = cells.iter().find(|(t, _)| t == "a b").expect("cell");
1263        assert_eq!(text, "a b");
1264        let kinds: Vec<&MarkKind> = marks.iter().map(|m| &m.kind).collect();
1265        assert_eq!(kinds, [&MarkKind::Underline, &MarkKind::Strong]);
1266    }
1267
1268    #[test]
1269    fn island_ids_are_deterministic_and_positional() {
1270        // Island-id determinism (DOCUMENT_STORAGE.md § Island-id determinism):
1271        // ids derive from mint position, so the same markdown imports to
1272        // byte-identical canonical JSON (ids included) and the ids are exactly
1273        // the `isl-{n}` sequence. This is the contract that keeps content-hashes
1274        // stable across producers; a random/ambient id would break it.
1275        let md = "![a](x)\n\n| h |\n|---|\n| c |";
1276        let a = imp(md);
1277        let b = imp(md);
1278        assert_eq!(a.to_canonical_json(), b.to_canonical_json());
1279        // Image slot then table island, minted in slot order.
1280        let ids: Vec<&str> = a.islands.iter().map(|i| i.id.as_str()).collect();
1281        assert_eq!(ids, ["isl-0", "isl-1"]);
1282    }
1283
1284    #[test]
1285    fn table_with_cell_image_degrades() {
1286        // GFM permits an inline image in a cell; the cell has no island slot to
1287        // carry it, so the alt text lands as plain cell text, the url is dropped,
1288        // and the island is Degraded (not the silent-Lossless lie).
1289        let rt = imp("| a | b |\n|---|---|\n| ![a cat](cat.png) | 2 |");
1290        assert_eq!(rt.islands.len(), 1);
1291        assert_eq!(rt.islands[0].island_type, "table");
1292        assert_eq!(rt.islands[0].loss, Loss::DEGRADED);
1293        // The dropped image left no nested island; alt survived as cell text.
1294        assert_eq!(rt.islands[0].props["rows"][0][0]["text"], "a cat");
1295        // A table with no cell image stays Lossless (regression guard).
1296        let plain = imp("| a | b |\n|---|---|\n| 1 | 2 |");
1297        assert_eq!(plain.islands[0].loss, Loss::LOSSLESS);
1298    }
1299
1300    #[test]
1301    fn image_is_inline_island() {
1302        let rt = imp("see ![a cat](cat.png) here");
1303        assert_eq!(rt.text, "see \u{FFFC} here");
1304        assert_eq!(rt.islands.len(), 1);
1305        assert_eq!(rt.islands[0].island_type, "image");
1306        assert_eq!(rt.islands[0].props["url"], "cat.png");
1307        assert_eq!(rt.islands[0].props["alt"], "a cat");
1308    }
1309
1310    #[test]
1311    fn empty_list_item_keeps_its_line() {
1312        // An empty `- ` item (here an empty bullet nested in an ordered item)
1313        // must not vanish (regression for the container-flush fix).
1314        let rt = imp("- a\n-\n- b");
1315        assert_eq!(rt.lines.len(), 3, "empty middle item preserved");
1316    }
1317
1318    #[test]
1319    fn empty_blockquote_keeps_its_line() {
1320        let rt = imp("> ");
1321        assert_eq!(rt.lines.len(), 1);
1322        assert_eq!(rt.lines[0].containers, vec![Container::Quote]);
1323    }
1324
1325    #[test]
1326    fn adjacent_sibling_lists_merge_is_stable() {
1327        // Documented canonicalization: two sibling bullet lists collapse to one.
1328        // Distinct markdown, one content, but the content is a fixed point.
1329        let rt = imp("* a\n\n+ b");
1330        let rt2 = from_markdown(&crate::export::to_markdown(&rt)).unwrap();
1331        assert_eq!(rt, rt2, "merged sibling lists still round-trip");
1332    }
1333
1334    #[test]
1335    fn empty_input_one_empty_line() {
1336        let rt = imp("");
1337        assert_eq!(rt.text, "");
1338        assert_eq!(rt.lines.len(), 1);
1339    }
1340
1341    #[test]
1342    fn mark_does_not_swallow_leading_newline() {
1343        // Regression (review finding 1): a mark starting a block must begin at
1344        // the content, not on the preceding line boundary.
1345        let rt = imp("a\n\n**b**");
1346        assert_eq!(rt.text, "a\nb");
1347        let m = &rt.marks[0];
1348        assert_eq!((m.start, m.end), (2, 3));
1349        assert_eq!(rt.text.chars().nth(m.start), Some('b'));
1350    }
1351
1352    #[test]
1353    fn import_and_editor_content_same_canonical_bytes() {
1354        // The freeze's central promise: equal content → equal bytes, whatever
1355        // the producer. Import of "a\n\n**b**" must byte-match a hand-built
1356        // editor content of the same content.
1357        let imported = imp("a\n\n**b**");
1358        let editor = Content {
1359            text: "a\nb".into(),
1360            lines: vec![
1361                Line {
1362                    kind: LineKind::Para,
1363                    containers: vec![],
1364                    continues: false,
1365                },
1366                Line {
1367                    kind: LineKind::Para,
1368                    containers: vec![],
1369                    continues: false,
1370                },
1371            ],
1372            marks: vec![Mark {
1373                start: 2,
1374                end: 3,
1375                kind: MarkKind::Strong,
1376            }],
1377            islands: vec![],
1378        };
1379        assert_eq!(imported.to_canonical_json(), editor.to_canonical_json());
1380    }
1381
1382    #[test]
1383    fn hard_break_is_a_continuation_line() {
1384        let rt = imp("line one\\\nline two");
1385        assert_eq!(rt.text, "line one\nline two");
1386        assert_eq!(rt.lines.len(), 2);
1387        assert!(!rt.lines[0].continues);
1388        assert!(rt.lines[1].continues, "hard break -> continuation line");
1389    }
1390
1391    #[test]
1392    fn heading_cannot_carry_hard_break() {
1393        // ATX headings are single-line: `## a  \nb` is a heading plus a separate
1394        // paragraph, never a heading with a continuation. (The heading→space
1395        // canonicalization in HardBreak handling is defensive for editor-built
1396        // content, unreachable via markdown import.)
1397        let rt = imp("## a  \nb");
1398        assert_eq!(rt.text, "a\nb");
1399        assert_eq!(rt.lines.len(), 2);
1400        assert_eq!(rt.lines[0].kind, LineKind::Heading { level: 2 });
1401        assert_eq!(rt.lines[1].kind, LineKind::Para);
1402        assert!(!rt.lines[1].continues, "separate block, not a continuation");
1403    }
1404
1405    #[test]
1406    fn astral_positions_are_usv() {
1407        let rt = imp("a😀**b**");
1408        // 'a'(0) '😀'(1) 'b'(2): strong over "b" is 2..3 in USV.
1409        assert_eq!(rt.text, "a😀b");
1410        assert!(rt
1411            .marks
1412            .iter()
1413            .any(|m| m.start == 2 && m.end == 3 && m.kind == MarkKind::Strong));
1414    }
1415}