Skip to main content

quillmark_content/
import.rs

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