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