Skip to main content

common/parser_tools/
content_parser.rs

1use crate::entities::{Alignment, ListStyle, MarkerType, SemanticRole, TextDirection};
2use crate::parser_tools::djot_options::DjotImportOptions;
3
4/// An inline image recovered by a parser, before it becomes an `ImageAnchor`.
5///
6/// `src` is whatever the source document pointed at — a relative path, a bare
7/// name, a URL. Parsers do not resolve it; that is the embedding application's
8/// job, and this crate never touches the filesystem.
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct ParsedImage {
11    pub src: String,
12    pub alt: String,
13    /// Display size in pixels, `0` when the source did not state one.
14    ///
15    /// Djot and HTML both carry these as attributes (`{width=800}`,
16    /// `<img width=…>`); Markdown has no syntax for them, so a Markdown import
17    /// leaves both zero and the caller supplies intrinsic dimensions.
18    pub width: i64,
19    pub height: i64,
20}
21
22/// A parsed inline span with formatting info.
23///
24/// A span carries *either* text or an image, never both: an image span's `text`
25/// is empty and its description lives in [`ParsedImage::alt`], so alt text
26/// cannot leak into the block's prose (and therefore cannot be counted as
27/// manuscript words or matched by a search).
28#[derive(Debug, Clone, Default)]
29pub struct ParsedSpan {
30    pub text: String,
31    pub bold: bool,
32    pub italic: bool,
33    pub underline: bool,
34    pub strikeout: bool,
35    pub code: bool,
36    /// Superscript (djot `^x^`). Maps to `CharVerticalAlignment::SuperScript`.
37    pub superscript: bool,
38    /// Subscript (djot `~x~`). Maps to `CharVerticalAlignment::SubScript`.
39    pub subscript: bool,
40    pub link_href: Option<String>,
41    /// Set when this span is an inline image rather than text.
42    pub image: Option<ParsedImage>,
43    /// Set when this span is a footnote *reference* rather than text — the
44    /// label naming the note, never the number a reader sees.
45    pub footnote_ref: Option<String>,
46}
47
48/// A parsed table cell containing inline spans.
49#[derive(Debug, Clone)]
50pub struct ParsedTableCell {
51    pub spans: Vec<ParsedSpan>,
52}
53
54/// A parsed table extracted from markdown or HTML.
55#[derive(Debug, Clone)]
56pub struct ParsedTable {
57    /// Number of header rows (typically 1 for markdown tables).
58    pub header_rows: usize,
59    /// All rows (header + body), each containing cells with their inline spans.
60    pub rows: Vec<Vec<ParsedTableCell>>,
61    /// Blockquote nesting depth at the point the table appeared
62    /// (0 = not inside a blockquote), mirroring `ParsedBlock::blockquote_depth`.
63    pub blockquote_depth: u32,
64}
65
66/// A parsed element: either a block or a table.
67#[derive(Debug, Clone)]
68pub enum ParsedElement {
69    Block(ParsedBlock),
70    Table(ParsedTable),
71    /// A footnote definition: the label its references name, and the blocks
72    /// making up its body.
73    ///
74    /// Separate from `Block` because a definition is not part of the flow it was
75    /// written in — it belongs wherever the output format puts notes, which the
76    /// importer expresses by giving it a detached frame of its own.
77    FootnoteDefinition {
78        label: String,
79        blocks: Vec<ParsedBlock>,
80    },
81}
82
83impl ParsedElement {
84    /// Extract blocks, flattening tables into one block per cell.
85    /// Use when table structure is not needed.
86    pub fn flatten_to_blocks(elements: Vec<ParsedElement>) -> Vec<ParsedBlock> {
87        let mut blocks = Vec::new();
88        for elem in elements {
89            match elem {
90                ParsedElement::Block(b) => blocks.push(b),
91                // A definition is not part of the flow it was written in — it
92                // belongs wherever the format puts notes. Flattening it in
93                // would splice a note's body into the prose at the point the
94                // definition happened to be typed.
95                ParsedElement::FootnoteDefinition { .. } => {}
96                ParsedElement::Table(t) => {
97                    for row in t.rows {
98                        for cell in row {
99                            blocks.push(ParsedBlock {
100                                spans: cell.spans,
101                                heading_level: None,
102                                list_style: None,
103                                list_indent: 0,
104                                list_prefix: String::new(),
105                                list_suffix: String::new(),
106                                marker: None,
107                                is_code_block: false,
108                                code_language: None,
109                                blockquote_depth: t.blockquote_depth,
110                                line_height: None,
111                                non_breakable_lines: None,
112                                page_break_before: None,
113                                direction: None,
114                                background_color: None,
115                                alignment: None,
116                                top_margin: None,
117                                text_indent: None,
118                                semantic_role: None,
119                            });
120                        }
121                    }
122                }
123            }
124        }
125        if blocks.is_empty() {
126            blocks.push(ParsedBlock {
127                spans: vec![ParsedSpan {
128                    text: String::new(),
129                    ..Default::default()
130                }],
131                heading_level: None,
132                list_style: None,
133                list_indent: 0,
134                list_prefix: String::new(),
135                list_suffix: String::new(),
136                marker: None,
137                is_code_block: false,
138                code_language: None,
139                blockquote_depth: 0,
140                line_height: None,
141                non_breakable_lines: None,
142                page_break_before: None,
143                direction: None,
144                background_color: None,
145                alignment: None,
146                top_margin: None,
147                text_indent: None,
148                semantic_role: None,
149            });
150        }
151        blocks
152    }
153}
154
155/// A parsed block (paragraph, heading, list item, code block)
156#[derive(Debug, Clone)]
157pub struct ParsedBlock {
158    pub spans: Vec<ParsedSpan>,
159    pub heading_level: Option<i64>,
160    pub list_style: Option<ListStyle>,
161    pub list_indent: u32,
162    /// Ordered-list delimiter prefix (e.g. `"("` for djot `(1)` lists; empty
163    /// otherwise). Stored on the `List` entity for round-trip fidelity.
164    pub list_prefix: String,
165    /// Ordered-list delimiter suffix (`"."` for `1.`, `")"` for `1)`/`(1)`;
166    /// empty for unordered lists).
167    pub list_suffix: String,
168    /// Task-list checkbox marker (djot `- [ ]` / `- [x]`). Maps to
169    /// `Block.fmt_marker`. `None` for non-task blocks.
170    pub marker: Option<MarkerType>,
171    pub is_code_block: bool,
172    pub code_language: Option<String>,
173    pub blockquote_depth: u32,
174    pub line_height: Option<i64>,
175    pub non_breakable_lines: Option<bool>,
176    /// Start this block on a new page (djot `{page_break_before=true}`). Maps to
177    /// `Block.fmt_page_break_before`. `None` when absent.
178    pub page_break_before: Option<bool>,
179    pub direction: Option<TextDirection>,
180    pub background_color: Option<String>,
181    /// Paragraph alignment (djot `{alignment=left|right|center|justify}`). Maps
182    /// to `Block.fmt_alignment`. `None` when no alignment attribute is present.
183    pub alignment: Option<Alignment>,
184    /// This block's own space-above (djot `{top_margin=<int>}`). Maps to
185    /// `Block.fmt_top_margin` and overrides the document-wide paragraph
186    /// spacing for this block alone. `None` when absent.
187    pub top_margin: Option<i64>,
188    /// This block's own first-line indent (djot `{text_indent=<int>}`). Maps to
189    /// `Block.fmt_text_indent` and overrides the document-wide first-line
190    /// indent for this block alone. `None` when absent.
191    pub text_indent: Option<i64>,
192    /// The enclosing blockquote's semantic role (djot `{semantic_role=epigraph}`),
193    /// written on the quote's first block because block attributes are the only channel
194    /// djot offers. The importer lifts it onto the `Frame`, where it belongs.
195    pub semantic_role: Option<SemanticRole>,
196}
197
198impl ParsedBlock {
199    /// Returns `true` when this block carries no block-level formatting,
200    /// meaning its content is purely inline.
201    pub fn is_inline_only(&self) -> bool {
202        self.heading_level.is_none()
203            && self.list_style.is_none()
204            && !self.is_code_block
205            && self.blockquote_depth == 0
206            && self.line_height.is_none()
207            && self.non_breakable_lines.is_none()
208            && self.page_break_before.is_none()
209            && self.direction.is_none()
210            && self.background_color.is_none()
211            && self.alignment.is_none()
212            && self.top_margin.is_none()
213            && self.text_indent.is_none()
214    }
215}
216
217// ─── Markdown parsing ────────────────────────────────────────────────
218
219/// Labels referenced as `[^label]` in `markdown` with no `[^label]: ...`
220/// definition anywhere in the document.
221///
222/// pulldown-cmark's default footnote mode (`Options::ENABLE_FOOTNOTES`, i.e.
223/// GitHub's syntax) only emits `Event::FootnoteReference` for a label some
224/// `Tag::FootnoteDefinition` actually defines (verified against 0.13) — an
225/// undefined reference silently decomposes into three ordinary `Text` events
226/// (`"["`, `"^label"`, `"]"`) instead, indistinguishable from a reader having
227/// typed literal brackets. `Options::ENABLE_OLD_FOOTNOTES` recognises it, but
228/// as `parse_markdown`'s doc comment explains, trades away multi-paragraph
229/// definition bodies to get there. Neither flag alone gives both, so
230/// `parse_markdown` hands pulldown-cmark a synthetic empty definition for
231/// every such label instead — real enough for it to recognise the reference,
232/// thrown away again before `elements` is returned.
233///
234/// Finding "every such label" is two parts: which labels pulldown-cmark's own
235/// block scanner (a first pass, at the caller's `options`) already recognises
236/// as truly defined, and which `[^…]`-shaped spans exist in the raw text at
237/// all. The second part is a plain scan, not a parser — it can over-match
238/// (inside a code span, inside a fenced block, a coincidental `[^x]:` in the
239/// middle of a sentence that real block parsing would never treat as a
240/// definition), but over-matching only ever produces an unused synthetic
241/// definition: pulldown-cmark's real, correct parse of the augmented text is
242/// what decides whether any span actually becomes a reference, and
243/// `parse_markdown` drops every synthesized definition regardless of whether
244/// that happened.
245fn dangling_footnote_labels(
246    markdown: &str,
247    options: pulldown_cmark::Options,
248) -> std::collections::BTreeSet<String> {
249    use pulldown_cmark::{Event, Parser, Tag};
250
251    let mut defined: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
252    for event in Parser::new_ext(markdown, options) {
253        if let Event::Start(Tag::FootnoteDefinition(label)) = event {
254            defined.insert(label.to_string());
255        }
256    }
257
258    let mut referenced: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
259    let bytes = markdown.as_bytes();
260    let mut search_from = 0usize;
261    while let Some(rel) = markdown[search_from..].find("[^") {
262        let open = search_from + rel;
263        let label_start = open + 2;
264        let Some(close_rel) = markdown[label_start..].find(']') else {
265            break;
266        };
267        let close = label_start + close_rel;
268        let label = &markdown[label_start..close];
269        // `]:` immediately after is definition syntax, not a reference —
270        // pulldown-cmark's own scan above already supplied the ground truth
271        // for which labels are truly defined; this only needs to avoid
272        // counting a definition's own label as a "reference" candidate.
273        let looks_like_definition = bytes.get(close + 1) == Some(&b':');
274        if !label.is_empty() && !looks_like_definition && !label.chars().any(char::is_whitespace) {
275            referenced.insert(label.to_string());
276        }
277        search_from = close + 1;
278    }
279
280    referenced.difference(&defined).cloned().collect()
281}
282
283pub fn parse_markdown(markdown: &str) -> Vec<ParsedElement> {
284    use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
285
286    // ENABLE_FOOTNOTES is required for pulldown-cmark to parse `[^label]`
287    // references and `[^label]: body` definitions at all — without it both
288    // fall through as plain link-reference-shaped text. Deliberately NOT
289    // `ENABLE_OLD_FOOTNOTES` too: that variant makes a reference with no
290    // definition survive (see `dangling_footnote_labels`), but in exchange
291    // breaks multi-paragraph definition bodies — a blank line before an
292    // indented continuation escapes the definition and becomes a sibling
293    // code block, which is exactly the shape this crate's own Markdown
294    // exporter writes for a note with more than one paragraph. Getting both
295    // is `dangling_footnote_labels`'s job, not an `Options` flag's.
296    let options = Options::ENABLE_STRIKETHROUGH
297        | Options::ENABLE_TABLES
298        | Options::ENABLE_TASKLISTS
299        | Options::ENABLE_FOOTNOTES;
300
301    // See `dangling_footnote_labels`: pulldown-cmark's GFM-style footnotes
302    // only recognise `[^label]` as a reference when some definition exists
303    // for it anywhere in the document, so an undefined one — the normal
304    // state for a host that owns note bodies itself — has to be given a
305    // throwaway definition before parsing, or it silently becomes the
306    // literal text "[^label]" instead of surviving as a reference.
307    let dangling = dangling_footnote_labels(markdown, options);
308    let augmented_owner;
309    let source: &str = if dangling.is_empty() {
310        markdown
311    } else {
312        augmented_owner = dangling
313            .iter()
314            .fold(markdown.to_string(), |mut acc, label| {
315                acc.push_str("\n\n[^");
316                acc.push_str(label);
317                acc.push_str("]:\n");
318                acc
319            });
320        &augmented_owner
321    };
322    let parser = Parser::new_ext(source, options);
323
324    let mut elements: Vec<ParsedElement> = Vec::new();
325    let mut current_spans: Vec<ParsedSpan> = Vec::new();
326    let mut current_heading: Option<i64> = None;
327    let mut current_list_style: Option<ListStyle> = None;
328    let mut is_code_block = false;
329    let mut code_language: Option<String> = None;
330    let mut blockquote_depth: u32 = 0;
331    let mut in_block = false;
332
333    // Formatting state stack
334    let mut bold = false;
335    let mut italic = false;
336    let mut strikeout = false;
337    let mut link_href: Option<String> = None;
338    // Set between an image's Start and End; its alt text arrives as Text events.
339    let mut pending_image: Option<ParsedImage> = None;
340
341    // The label and element index of the footnote definition currently open,
342    // if any. Mirrors `parse_djot`'s `footnote_open`: a definition's body is
343    // ordinary block content, parsed by the same machinery as everything
344    // else, then lifted back out at `End` into its own top-level element so
345    // it never becomes part of the flow it was written in. CommonMark
346    // footnote definitions cannot nest, so one slot suffices.
347    let mut footnote_open: Option<(String, usize)> = None;
348
349    // List style stack for nested lists (also tracks nesting depth)
350    let mut list_stack: Vec<Option<ListStyle>> = Vec::new();
351    let mut current_list_indent: u32 = 0;
352
353    // Table tracking state
354    let mut in_table = false;
355    let mut in_table_head = false;
356    let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
357    let mut current_row_cells: Vec<ParsedTableCell> = Vec::new();
358    let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
359    let mut table_header_rows: usize = 0;
360
361    for event in parser {
362        match event {
363            Event::Start(Tag::Paragraph) => {
364                in_block = true;
365                current_heading = None;
366                is_code_block = false;
367            }
368            Event::End(TagEnd::Paragraph) => {
369                if !current_spans.is_empty() || in_block {
370                    elements.push(ParsedElement::Block(ParsedBlock {
371                        spans: std::mem::take(&mut current_spans),
372                        heading_level: current_heading.take(),
373                        list_style: current_list_style.clone(),
374                        list_indent: current_list_indent,
375                        list_prefix: String::new(),
376                        list_suffix: String::new(),
377                        marker: None,
378                        is_code_block: false,
379                        code_language: None,
380                        blockquote_depth,
381                        line_height: None,
382                        non_breakable_lines: None,
383                        page_break_before: None,
384                        direction: None,
385                        background_color: None,
386                        alignment: None,
387                        top_margin: None,
388                        text_indent: None,
389                        semantic_role: None,
390                    }));
391                }
392                in_block = false;
393                current_list_style = None;
394            }
395            Event::Start(Tag::Heading { level, .. }) => {
396                in_block = true;
397                current_heading = Some(heading_level_to_i64(level));
398                is_code_block = false;
399            }
400            Event::End(TagEnd::Heading(_)) => {
401                elements.push(ParsedElement::Block(ParsedBlock {
402                    spans: std::mem::take(&mut current_spans),
403                    heading_level: current_heading.take(),
404                    list_style: None,
405                    list_indent: 0,
406                    list_prefix: String::new(),
407                    list_suffix: String::new(),
408                    marker: None,
409                    is_code_block: false,
410                    code_language: None,
411                    blockquote_depth,
412                    line_height: None,
413                    non_breakable_lines: None,
414                    page_break_before: None,
415                    direction: None,
416                    background_color: None,
417                    alignment: None,
418                    top_margin: None,
419                    text_indent: None,
420                    semantic_role: None,
421                }));
422                in_block = false;
423            }
424            Event::Start(Tag::List(ordered)) => {
425                let style = if ordered.is_some() {
426                    Some(ListStyle::Decimal)
427                } else {
428                    Some(ListStyle::Disc)
429                };
430                list_stack.push(style);
431            }
432            Event::End(TagEnd::List(_)) => {
433                list_stack.pop();
434            }
435            Event::Start(Tag::Item) => {
436                // Flush any accumulated spans from the parent item before
437                // starting a child item in a tight list
438                if !current_spans.is_empty() {
439                    elements.push(ParsedElement::Block(ParsedBlock {
440                        spans: std::mem::take(&mut current_spans),
441                        heading_level: None,
442                        list_style: current_list_style.clone(),
443                        list_indent: current_list_indent,
444                        list_prefix: String::new(),
445                        list_suffix: String::new(),
446                        marker: None,
447                        is_code_block: false,
448                        code_language: None,
449                        blockquote_depth,
450                        line_height: None,
451                        non_breakable_lines: None,
452                        page_break_before: None,
453                        direction: None,
454                        background_color: None,
455                        alignment: None,
456                        top_margin: None,
457                        text_indent: None,
458                        semantic_role: None,
459                    }));
460                }
461                in_block = true;
462                current_list_style = list_stack.last().cloned().flatten();
463                current_list_indent = if list_stack.is_empty() {
464                    0
465                } else {
466                    (list_stack.len() - 1) as u32
467                };
468            }
469            Event::End(TagEnd::Item) => {
470                // The paragraph inside the item will have already been flushed,
471                // but if there was no inner paragraph (tight list), flush now.
472                if !current_spans.is_empty() {
473                    elements.push(ParsedElement::Block(ParsedBlock {
474                        spans: std::mem::take(&mut current_spans),
475                        heading_level: None,
476                        list_style: current_list_style.clone(),
477                        list_indent: current_list_indent,
478                        list_prefix: String::new(),
479                        list_suffix: String::new(),
480                        marker: None,
481                        is_code_block: false,
482                        code_language: None,
483                        blockquote_depth,
484                        line_height: None,
485                        non_breakable_lines: None,
486                        page_break_before: None,
487                        direction: None,
488                        background_color: None,
489                        alignment: None,
490                        top_margin: None,
491                        text_indent: None,
492                        semantic_role: None,
493                    }));
494                }
495                in_block = false;
496                current_list_style = None;
497            }
498            Event::Start(Tag::CodeBlock(kind)) => {
499                in_block = true;
500                is_code_block = true;
501                code_language = match &kind {
502                    pulldown_cmark::CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
503                        Some(lang.to_string())
504                    }
505                    _ => None,
506                };
507            }
508            Event::End(TagEnd::CodeBlock) => {
509                // pulldown-cmark appends a trailing '\n' to code block text — strip it
510                if let Some(last) = current_spans.last_mut()
511                    && last.text.ends_with('\n')
512                {
513                    last.text.truncate(last.text.len() - 1);
514                }
515                elements.push(ParsedElement::Block(ParsedBlock {
516                    spans: std::mem::take(&mut current_spans),
517                    heading_level: None,
518                    list_style: None,
519                    list_indent: 0,
520                    list_prefix: String::new(),
521                    list_suffix: String::new(),
522                    marker: None,
523                    is_code_block: true,
524                    code_language: code_language.take(),
525                    blockquote_depth,
526                    line_height: None,
527                    non_breakable_lines: None,
528                    page_break_before: None,
529                    direction: None,
530                    background_color: None,
531                    alignment: None,
532                    top_margin: None,
533                    text_indent: None,
534                    semantic_role: None,
535                }));
536                in_block = false;
537                is_code_block = false;
538            }
539            // ─── Table events ───────────────────────────────────────
540            Event::Start(Tag::Table(_)) => {
541                in_table = true;
542                in_table_head = false;
543                table_rows.clear();
544                current_row_cells.clear();
545                current_cell_spans.clear();
546                table_header_rows = 0;
547            }
548            Event::End(TagEnd::Table) => {
549                elements.push(ParsedElement::Table(ParsedTable {
550                    header_rows: table_header_rows,
551                    rows: std::mem::take(&mut table_rows),
552                    blockquote_depth,
553                }));
554                in_table = false;
555            }
556            Event::Start(Tag::TableHead) => {
557                in_table_head = true;
558                current_row_cells.clear();
559            }
560            Event::End(TagEnd::TableHead) => {
561                // Flush the header row
562                table_rows.push(std::mem::take(&mut current_row_cells));
563                table_header_rows += 1;
564                in_table_head = false;
565            }
566            Event::Start(Tag::TableRow) => {
567                current_row_cells.clear();
568            }
569            Event::End(TagEnd::TableRow) if !in_table_head => {
570                // Body rows only — header row is flushed in End(TableHead)
571                table_rows.push(std::mem::take(&mut current_row_cells));
572            }
573            Event::Start(Tag::TableCell) => {
574                current_cell_spans.clear();
575            }
576            Event::End(TagEnd::TableCell) => {
577                current_row_cells.push(ParsedTableCell {
578                    spans: std::mem::take(&mut current_cell_spans),
579                });
580            }
581            // ─── Inline formatting ──────────────────────────────────
582            Event::Start(Tag::Emphasis) => {
583                italic = true;
584            }
585            Event::End(TagEnd::Emphasis) => {
586                italic = false;
587            }
588            Event::Start(Tag::Strong) => {
589                bold = true;
590            }
591            Event::End(TagEnd::Strong) => {
592                bold = false;
593            }
594            Event::Start(Tag::Strikethrough) => {
595                strikeout = true;
596            }
597            Event::End(TagEnd::Strikethrough) => {
598                strikeout = false;
599            }
600            Event::Start(Tag::Link { dest_url, .. }) => {
601                link_href = Some(dest_url.to_string());
602            }
603            Event::End(TagEnd::Link) => {
604                link_href = None;
605            }
606            // Markdown has no syntax for display size, so width/height stay 0
607            // and the caller supplies the image's intrinsic dimensions.
608            Event::Start(Tag::Image { dest_url, .. }) => {
609                pending_image = Some(ParsedImage {
610                    src: dest_url.to_string(),
611                    alt: String::new(),
612                    width: 0,
613                    height: 0,
614                });
615            }
616            Event::End(TagEnd::Image) => {
617                if let Some(image) = pending_image.take() {
618                    let span = ParsedSpan {
619                        text: String::new(),
620                        bold,
621                        italic,
622                        underline: false,
623                        strikeout,
624                        code: false,
625                        superscript: false,
626                        subscript: false,
627                        link_href: link_href.clone(),
628                        image: Some(image),
629                        footnote_ref: None,
630                    };
631                    if in_table {
632                        current_cell_spans.push(span);
633                    } else {
634                        if !in_block {
635                            in_block = true;
636                        }
637                        current_spans.push(span);
638                    }
639                }
640            }
641            Event::Text(text) => {
642                // Inside an image this is its alt text, which pulldown-cmark
643                // emits as an ordinary Text event. Without this guard it fell
644                // through and landed in the paragraph as prose — the image was
645                // dropped and its description silently became manuscript text.
646                if let Some(img) = pending_image.as_mut() {
647                    img.alt.push_str(&text);
648                    continue;
649                }
650                let span = ParsedSpan {
651                    text: text.to_string(),
652                    bold,
653                    italic,
654                    underline: false,
655                    strikeout,
656                    code: is_code_block,
657                    superscript: false,
658                    subscript: false,
659                    link_href: link_href.clone(),
660                    image: None,
661                    footnote_ref: None,
662                };
663                if in_table {
664                    current_cell_spans.push(span);
665                } else {
666                    if !in_block {
667                        in_block = true;
668                    }
669                    current_spans.push(span);
670                }
671            }
672            Event::Code(text) => {
673                let span = ParsedSpan {
674                    text: text.to_string(),
675                    bold,
676                    italic,
677                    underline: false,
678                    strikeout,
679                    code: true,
680                    superscript: false,
681                    subscript: false,
682                    link_href: link_href.clone(),
683                    image: None,
684                    footnote_ref: None,
685                };
686                if in_table {
687                    current_cell_spans.push(span);
688                } else {
689                    if !in_block {
690                        in_block = true;
691                    }
692                    current_spans.push(span);
693                }
694            }
695            Event::SoftBreak => {
696                let span = ParsedSpan {
697                    text: " ".to_string(),
698                    bold,
699                    italic,
700                    underline: false,
701                    strikeout,
702                    code: false,
703                    superscript: false,
704                    subscript: false,
705                    link_href: link_href.clone(),
706                    image: None,
707                    footnote_ref: None,
708                };
709                if in_table {
710                    current_cell_spans.push(span);
711                } else {
712                    current_spans.push(span);
713                }
714            }
715            Event::HardBreak if !current_spans.is_empty() || in_block => {
716                // Finalize current block
717                elements.push(ParsedElement::Block(ParsedBlock {
718                    spans: std::mem::take(&mut current_spans),
719                    heading_level: current_heading.take(),
720                    list_style: current_list_style.clone(),
721                    list_indent: current_list_indent,
722                    list_prefix: String::new(),
723                    list_suffix: String::new(),
724                    marker: None,
725                    is_code_block,
726                    code_language: code_language.clone(),
727                    blockquote_depth,
728                    line_height: None,
729                    non_breakable_lines: None,
730                    page_break_before: None,
731                    direction: None,
732                    background_color: None,
733                    alignment: None,
734                    top_margin: None,
735                    text_indent: None,
736                    semantic_role: None,
737                }));
738            }
739            Event::Start(Tag::BlockQuote(_)) => {
740                blockquote_depth += 1;
741            }
742            Event::End(TagEnd::BlockQuote(_)) => {
743                blockquote_depth = blockquote_depth.saturating_sub(1);
744            }
745            // ── Footnote definitions ──
746            //
747            // `[^label]: body` opens a block-level container; its body is
748            // ordinary paragraphs/lists/etc., pushed onto `elements` by the
749            // ordinary machinery above. `End` drains everything pushed since
750            // `Start` back out into one `FootnoteDefinition`, exactly as
751            // `parse_djot` does for the same `[^label]: body` syntax.
752            // Flushing any spans left open by an interrupted block first
753            // matches every other container boundary in this parser
754            // (List/Item/BlockQuote).
755            Event::Start(Tag::FootnoteDefinition(label)) => {
756                if !current_spans.is_empty() {
757                    elements.push(ParsedElement::Block(ParsedBlock {
758                        spans: std::mem::take(&mut current_spans),
759                        heading_level: current_heading.take(),
760                        list_style: current_list_style.clone(),
761                        list_indent: current_list_indent,
762                        list_prefix: String::new(),
763                        list_suffix: String::new(),
764                        marker: None,
765                        is_code_block: false,
766                        code_language: None,
767                        blockquote_depth,
768                        line_height: None,
769                        non_breakable_lines: None,
770                        page_break_before: None,
771                        direction: None,
772                        background_color: None,
773                        alignment: None,
774                        top_margin: None,
775                        text_indent: None,
776                        semantic_role: None,
777                    }));
778                }
779                footnote_open = Some((label.to_string(), elements.len()));
780            }
781            Event::End(TagEnd::FootnoteDefinition) => {
782                if !current_spans.is_empty() {
783                    elements.push(ParsedElement::Block(ParsedBlock {
784                        spans: std::mem::take(&mut current_spans),
785                        heading_level: current_heading.take(),
786                        list_style: current_list_style.clone(),
787                        list_indent: current_list_indent,
788                        list_prefix: String::new(),
789                        list_suffix: String::new(),
790                        marker: None,
791                        is_code_block: false,
792                        code_language: None,
793                        blockquote_depth,
794                        line_height: None,
795                        non_breakable_lines: None,
796                        page_break_before: None,
797                        direction: None,
798                        background_color: None,
799                        alignment: None,
800                        top_margin: None,
801                        text_indent: None,
802                        semantic_role: None,
803                    }));
804                }
805                if let Some((label, start)) = footnote_open.take() {
806                    let blocks: Vec<ParsedBlock> = elements
807                        .drain(start..)
808                        .filter_map(|e| match e {
809                            ParsedElement::Block(b) => Some(b),
810                            // A table inside a footnote is not representable
811                            // as note content, same limitation as djot.
812                            _ => None,
813                        })
814                        .collect();
815                    elements.push(ParsedElement::FootnoteDefinition { label, blocks });
816                }
817            }
818            // A footnote reference. pulldown-cmark emits this whether or not
819            // a matching `[^label]:` definition exists anywhere in the
820            // document — a dangling reference (the normal state for a host
821            // that owns note bodies itself) must survive just the same,
822            // mirroring `parse_djot`'s `E::FootnoteReference` handling.
823            Event::FootnoteReference(label) => {
824                let span = ParsedSpan {
825                    text: String::new(),
826                    bold,
827                    italic,
828                    underline: false,
829                    strikeout,
830                    code: false,
831                    superscript: false,
832                    subscript: false,
833                    link_href: link_href.clone(),
834                    image: None,
835                    footnote_ref: Some(label.to_string()),
836                };
837                if in_table {
838                    current_cell_spans.push(span);
839                } else {
840                    if !in_block {
841                        in_block = true;
842                    }
843                    current_spans.push(span);
844                }
845            }
846            _ => {}
847        }
848    }
849
850    // Flush any remaining content
851    if !current_spans.is_empty() {
852        elements.push(ParsedElement::Block(ParsedBlock {
853            spans: std::mem::take(&mut current_spans),
854            heading_level: current_heading,
855            list_style: current_list_style,
856            list_indent: current_list_indent,
857            list_prefix: String::new(),
858            list_suffix: String::new(),
859            marker: None,
860            is_code_block,
861            code_language: code_language.take(),
862            blockquote_depth,
863            line_height: None,
864            non_breakable_lines: None,
865            page_break_before: None,
866            direction: None,
867            background_color: None,
868            alignment: None,
869            top_margin: None,
870            text_indent: None,
871            semantic_role: None,
872        }));
873    }
874
875    // Drop the throwaway definitions `dangling_footnote_labels` asked for —
876    // they exist only to make pulldown-cmark recognise the reference as
877    // real, never to become a note the document owns. `elements` cannot
878    // become empty from this: a label reached `dangling` only via a
879    // reference actually present in `markdown`, so its surrounding block
880    // survives even with the synthetic definition gone.
881    if !dangling.is_empty() {
882        elements.retain(
883            |e| !matches!(e, ParsedElement::FootnoteDefinition { label, .. } if dangling.contains(label)),
884        );
885    }
886
887    // If no elements were parsed, create a single empty paragraph
888    if elements.is_empty() {
889        elements.push(ParsedElement::Block(ParsedBlock {
890            spans: vec![ParsedSpan {
891                text: String::new(),
892                ..Default::default()
893            }],
894            heading_level: None,
895            list_style: None,
896            list_indent: 0,
897            list_prefix: String::new(),
898            list_suffix: String::new(),
899            marker: None,
900            is_code_block: false,
901            code_language: None,
902            blockquote_depth: 0,
903            line_height: None,
904            non_breakable_lines: None,
905            page_break_before: None,
906            direction: None,
907            background_color: None,
908            alignment: None,
909            top_margin: None,
910            text_indent: None,
911            semantic_role: None,
912        }));
913    }
914
915    elements
916}
917
918fn heading_level_to_i64(level: pulldown_cmark::HeadingLevel) -> i64 {
919    use pulldown_cmark::HeadingLevel;
920    match level {
921        HeadingLevel::H1 => 1,
922        HeadingLevel::H2 => 2,
923        HeadingLevel::H3 => 3,
924        HeadingLevel::H4 => 4,
925        HeadingLevel::H5 => 5,
926        HeadingLevel::H6 => 6,
927    }
928}
929
930// ─── HTML parsing ────────────────────────────────────────────────────
931
932use scraper::Node;
933
934/// Parsed CSS block-level styles from an inline `style` attribute.
935#[derive(Debug, Clone, Default)]
936struct BlockStyles {
937    line_height: Option<i64>,
938    non_breakable_lines: Option<bool>,
939    page_break_before: Option<bool>,
940    direction: Option<TextDirection>,
941    background_color: Option<String>,
942}
943
944/// Parse relevant CSS properties from an inline style string.
945/// Handles: line-height, white-space, break-before/page-break-before, direction,
946/// background-color.
947fn parse_block_styles(style: &str) -> BlockStyles {
948    let mut result = BlockStyles::default();
949    for part in style.split(';') {
950        let part = part.trim();
951        if let Some((prop, val)) = part.split_once(':') {
952            let prop = prop.trim().to_ascii_lowercase();
953            let val = val.trim();
954            match prop.as_str() {
955                "line-height" => {
956                    // Try parsing as a plain number (multiplier)
957                    if let Ok(v) = val.parse::<f64>() {
958                        result.line_height = Some((v * 1000.0) as i64);
959                    }
960                }
961                "white-space" if val == "pre" || val == "nowrap" || val == "pre-wrap" => {
962                    result.non_breakable_lines = Some(true);
963                }
964                // CSS3 `break-before` and its CSS2 predecessor `page-break-before` mean
965                // the same thing; both are read because both are written (browsers still
966                // want the legacy spelling, and so do most EPUB engines).
967                "break-before" | "page-break-before" => {
968                    result.page_break_before = match val.to_ascii_lowercase().as_str() {
969                        "page" | "always" | "left" | "right" | "recto" | "verso" => Some(true),
970                        "avoid" | "auto" => Some(false),
971                        _ => None,
972                    };
973                }
974                "direction" => {
975                    if val.eq_ignore_ascii_case("rtl") {
976                        result.direction = Some(TextDirection::RightToLeft);
977                    } else if val.eq_ignore_ascii_case("ltr") {
978                        result.direction = Some(TextDirection::LeftToRight);
979                    }
980                }
981                "background-color" | "background" => {
982                    result.background_color = Some(val.to_string());
983                }
984                _ => {}
985            }
986        }
987    }
988    result
989}
990
991pub fn parse_html(html: &str) -> Vec<ParsedBlock> {
992    ParsedElement::flatten_to_blocks(parse_html_elements(html))
993}
994
995/// Build an inline-image span from an `<img>` element, if it has a usable
996/// source.
997///
998/// `<img>` was matched by none of the HTML walker's three tag dispatches, so it
999/// fell into their wildcard arms and was dropped whole — silently, and unlike
1000/// Markdown not even leaving its alt text behind. That is the path a browser or
1001/// Word paste travels.
1002fn html_img_span(el: &scraper::node::Element, link_href: Option<String>) -> Option<ParsedSpan> {
1003    let src = el.attr("src")?;
1004    if src.is_empty() {
1005        return None;
1006    }
1007    let dim = |name: &str| -> i64 {
1008        el.attr(name)
1009            .and_then(|v| v.trim().trim_end_matches("px").parse::<i64>().ok())
1010            .filter(|n| *n > 0)
1011            .unwrap_or(0)
1012    };
1013    Some(ParsedSpan {
1014        text: String::new(),
1015        link_href,
1016        image: Some(ParsedImage {
1017            src: src.to_string(),
1018            alt: el.attr("alt").unwrap_or_default().to_string(),
1019            width: dim("width"),
1020            height: dim("height"),
1021        }),
1022        ..Default::default()
1023    })
1024}
1025
1026pub fn parse_html_elements(html: &str) -> Vec<ParsedElement> {
1027    use scraper::Html;
1028
1029    let fragment = Html::parse_fragment(html);
1030    let mut elements: Vec<ParsedElement> = Vec::new();
1031
1032    // Walk the DOM tree starting from the root
1033    let root = fragment.root_element();
1034
1035    #[derive(Clone, Default)]
1036    struct FmtState {
1037        bold: bool,
1038        italic: bool,
1039        underline: bool,
1040        strikeout: bool,
1041        code: bool,
1042        link_href: Option<String>,
1043    }
1044
1045    const MAX_RECURSION_DEPTH: usize = 256;
1046
1047    /// Collect inline spans from a `<td>` or `<th>` cell element.
1048    fn collect_cell_spans(
1049        node: ego_tree::NodeRef<Node>,
1050        state: &FmtState,
1051        spans: &mut Vec<ParsedSpan>,
1052        depth: usize,
1053    ) {
1054        if depth > MAX_RECURSION_DEPTH {
1055            return;
1056        }
1057        for child in node.children() {
1058            match child.value() {
1059                Node::Text(text) => {
1060                    let t = text.text.to_string();
1061                    if !t.is_empty() {
1062                        spans.push(ParsedSpan {
1063                            text: t,
1064                            bold: state.bold,
1065                            italic: state.italic,
1066                            underline: state.underline,
1067                            strikeout: state.strikeout,
1068                            code: state.code,
1069                            superscript: false,
1070                            subscript: false,
1071                            link_href: state.link_href.clone(),
1072                            image: None,
1073                            footnote_ref: None,
1074                        });
1075                    }
1076                }
1077                Node::Element(el) => {
1078                    let tag = el.name();
1079                    let mut new_state = state.clone();
1080                    match tag {
1081                        "b" | "strong" => new_state.bold = true,
1082                        "i" | "em" => new_state.italic = true,
1083                        "u" | "ins" => new_state.underline = true,
1084                        "s" | "del" | "strike" => new_state.strikeout = true,
1085                        "code" => new_state.code = true,
1086                        "a" => {
1087                            if let Some(href) = el.attr("href") {
1088                                new_state.link_href = Some(href.to_string());
1089                            }
1090                        }
1091                        "img" => {
1092                            if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1093                                spans.push(span);
1094                            }
1095                            continue;
1096                        }
1097                        _ => {}
1098                    }
1099                    collect_cell_spans(child, &new_state, spans, depth + 1);
1100                }
1101                _ => {}
1102            }
1103        }
1104    }
1105
1106    /// Parse a `<table>` element into a ParsedTable.
1107    fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
1108        let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1109        let mut header_rows: usize = 0;
1110
1111        fn collect_rows(
1112            node: ego_tree::NodeRef<Node>,
1113            rows: &mut Vec<Vec<ParsedTableCell>>,
1114            header_rows: &mut usize,
1115            in_thead: bool,
1116        ) {
1117            for child in node.children() {
1118                if let Node::Element(el) = child.value() {
1119                    match el.name() {
1120                        "thead" => collect_rows(child, rows, header_rows, true),
1121                        "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
1122                        "tr" => {
1123                            let mut cells: Vec<ParsedTableCell> = Vec::new();
1124                            for td in child.children() {
1125                                if let Node::Element(td_el) = td.value()
1126                                    && matches!(td_el.name(), "td" | "th")
1127                                {
1128                                    let mut spans = Vec::new();
1129                                    let state = FmtState::default();
1130                                    collect_cell_spans(td, &state, &mut spans, 0);
1131                                    if spans.is_empty() {
1132                                        spans.push(ParsedSpan::default());
1133                                    }
1134                                    cells.push(ParsedTableCell { spans });
1135                                }
1136                            }
1137                            if !cells.is_empty() {
1138                                rows.push(cells);
1139                                if in_thead {
1140                                    *header_rows += 1;
1141                                }
1142                            }
1143                        }
1144                        _ => {}
1145                    }
1146                }
1147            }
1148        }
1149
1150        collect_rows(table_node, &mut rows, &mut header_rows, false);
1151
1152        // Tables without explicit <thead> but with <th> cells: treat first row as header
1153        if header_rows == 0 && !rows.is_empty() {
1154            header_rows = 1;
1155        }
1156
1157        ParsedTable {
1158            header_rows,
1159            rows,
1160            // The caller (`walk_node`) sets the real depth — this helper has
1161            // no visibility into the surrounding blockquote nesting.
1162            blockquote_depth: 0,
1163        }
1164    }
1165
1166    fn walk_node(
1167        node: ego_tree::NodeRef<Node>,
1168        state: &FmtState,
1169        elements: &mut Vec<ParsedElement>,
1170        current_list_style: &Option<ListStyle>,
1171        blockquote_depth: u32,
1172        list_depth: u32,
1173        depth: usize,
1174    ) {
1175        if depth > MAX_RECURSION_DEPTH {
1176            return;
1177        }
1178        match node.value() {
1179            Node::Element(el) => {
1180                let tag = el.name();
1181                let mut new_state = state.clone();
1182                let mut new_list_style = current_list_style.clone();
1183                let mut bq_depth = blockquote_depth;
1184                let mut new_list_depth = list_depth;
1185
1186                // Determine if this is a block-level element
1187                let is_block_tag = matches!(
1188                    tag,
1189                    "p" | "div"
1190                        | "h1"
1191                        | "h2"
1192                        | "h3"
1193                        | "h4"
1194                        | "h5"
1195                        | "h6"
1196                        | "li"
1197                        | "pre"
1198                        | "br"
1199                        | "blockquote"
1200                        | "body"
1201                        | "html"
1202                );
1203
1204                // Update formatting state
1205                match tag {
1206                    "b" | "strong" => new_state.bold = true,
1207                    "i" | "em" => new_state.italic = true,
1208                    "u" | "ins" => new_state.underline = true,
1209                    "s" | "del" | "strike" => new_state.strikeout = true,
1210                    "code" => new_state.code = true,
1211                    "a" => {
1212                        if let Some(href) = el.attr("href") {
1213                            new_state.link_href = Some(href.to_string());
1214                        }
1215                    }
1216                    "ul" => {
1217                        new_list_style = Some(ListStyle::Disc);
1218                        new_list_depth = list_depth + 1;
1219                    }
1220                    "ol" => {
1221                        new_list_style = Some(ListStyle::Decimal);
1222                        new_list_depth = list_depth + 1;
1223                    }
1224                    "blockquote" => {
1225                        bq_depth += 1;
1226                    }
1227                    _ => {}
1228                }
1229
1230                // Determine heading level
1231                let heading_level = match tag {
1232                    "h1" => Some(1),
1233                    "h2" => Some(2),
1234                    "h3" => Some(3),
1235                    "h4" => Some(4),
1236                    "h5" => Some(5),
1237                    "h6" => Some(6),
1238                    _ => None,
1239                };
1240
1241                let is_code_block = tag == "pre";
1242
1243                // Extract code language from <pre><code class="language-xxx">
1244                let code_language = if is_code_block {
1245                    node.children().find_map(|child| {
1246                        if let Node::Element(cel) = child.value()
1247                            && cel.name() == "code"
1248                            && let Some(cls) = cel.attr("class")
1249                        {
1250                            return cls
1251                                .split_whitespace()
1252                                .find_map(|c| c.strip_prefix("language-"))
1253                                .map(|l| l.to_string());
1254                        }
1255                        None
1256                    })
1257                } else {
1258                    None
1259                };
1260
1261                // Extract CSS styles from block-level elements
1262                let css = if is_block_tag {
1263                    el.attr("style").map(parse_block_styles).unwrap_or_default()
1264                } else {
1265                    BlockStyles::default()
1266                };
1267
1268                if tag == "table" {
1269                    // Parse table structure into a ParsedTable
1270                    let mut parsed_table = parse_table_element(node);
1271                    if !parsed_table.rows.is_empty() {
1272                        parsed_table.blockquote_depth = bq_depth;
1273                        elements.push(ParsedElement::Table(parsed_table));
1274                    }
1275                    return;
1276                }
1277
1278                if tag == "br" {
1279                    // <br> creates a new block
1280                    elements.push(ParsedElement::Block(ParsedBlock {
1281                        spans: vec![ParsedSpan {
1282                            text: String::new(),
1283                            ..Default::default()
1284                        }],
1285                        heading_level: None,
1286                        list_style: None,
1287                        list_indent: 0,
1288                        list_prefix: String::new(),
1289                        list_suffix: String::new(),
1290                        marker: None,
1291                        is_code_block: false,
1292                        code_language: None,
1293                        blockquote_depth: bq_depth,
1294                        line_height: None,
1295                        non_breakable_lines: None,
1296                        page_break_before: None,
1297                        direction: None,
1298                        background_color: None,
1299                        alignment: None,
1300                        top_margin: None,
1301                        text_indent: None,
1302                        semantic_role: None,
1303                    }));
1304                    return;
1305                }
1306
1307                if tag == "blockquote" {
1308                    // Blockquote is a container — recurse into children with increased depth
1309                    for child in node.children() {
1310                        walk_node(
1311                            child,
1312                            &new_state,
1313                            elements,
1314                            &new_list_style,
1315                            bq_depth,
1316                            new_list_depth,
1317                            depth + 1,
1318                        );
1319                    }
1320                } else if is_block_tag && tag != "br" {
1321                    // Start collecting spans for a new block.
1322                    // Use a temporary buffer so that nested block-level
1323                    // elements (e.g. sub-lists inside <li>) are collected
1324                    // separately and appended *after* the parent block.
1325                    let mut spans: Vec<ParsedSpan> = Vec::new();
1326                    let mut nested_elements: Vec<ParsedElement> = Vec::new();
1327                    collect_inline_spans(
1328                        node,
1329                        &new_state,
1330                        &mut spans,
1331                        &new_list_style,
1332                        &mut nested_elements,
1333                        bq_depth,
1334                        new_list_depth,
1335                        depth + 1,
1336                    );
1337
1338                    let list_style_for_block = if tag == "li" {
1339                        new_list_style.clone()
1340                    } else {
1341                        None
1342                    };
1343
1344                    let list_indent_for_block = if tag == "li" {
1345                        new_list_depth.saturating_sub(1)
1346                    } else {
1347                        0
1348                    };
1349
1350                    if !spans.is_empty() || heading_level.is_some() {
1351                        elements.push(ParsedElement::Block(ParsedBlock {
1352                            spans,
1353                            heading_level,
1354                            list_style: list_style_for_block,
1355                            list_indent: list_indent_for_block,
1356                            list_prefix: String::new(),
1357                            list_suffix: String::new(),
1358                            marker: None,
1359                            is_code_block,
1360                            code_language,
1361                            blockquote_depth: bq_depth,
1362                            line_height: css.line_height,
1363                            non_breakable_lines: css.non_breakable_lines,
1364                            page_break_before: css.page_break_before,
1365                            direction: css.direction,
1366                            background_color: css.background_color,
1367                            alignment: None,
1368                            top_margin: None,
1369                            text_indent: None,
1370                            semantic_role: None,
1371                        }));
1372                    }
1373                    // Append nested block elements after the parent block
1374                    elements.append(&mut nested_elements);
1375                } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
1376                    // Container elements: recurse into children
1377                    for child in node.children() {
1378                        walk_node(
1379                            child,
1380                            &new_state,
1381                            elements,
1382                            &new_list_style,
1383                            bq_depth,
1384                            new_list_depth,
1385                            depth + 1,
1386                        );
1387                    }
1388                } else {
1389                    // Inline element or unknown: recurse
1390                    for child in node.children() {
1391                        walk_node(
1392                            child,
1393                            &new_state,
1394                            elements,
1395                            current_list_style,
1396                            bq_depth,
1397                            list_depth,
1398                            depth + 1,
1399                        );
1400                    }
1401                }
1402            }
1403            Node::Text(text) => {
1404                let t = text.text.to_string();
1405                let trimmed = t.trim();
1406                if !trimmed.is_empty() {
1407                    // Bare text not in a block — create a paragraph
1408                    elements.push(ParsedElement::Block(ParsedBlock {
1409                        spans: vec![ParsedSpan {
1410                            text: trimmed.to_string(),
1411                            bold: state.bold,
1412                            italic: state.italic,
1413                            underline: state.underline,
1414                            strikeout: state.strikeout,
1415                            code: state.code,
1416                            superscript: false,
1417                            subscript: false,
1418                            link_href: state.link_href.clone(),
1419                            image: None,
1420                            footnote_ref: None,
1421                        }],
1422                        heading_level: None,
1423                        list_style: None,
1424                        list_indent: 0,
1425                        list_prefix: String::new(),
1426                        list_suffix: String::new(),
1427                        marker: None,
1428                        is_code_block: false,
1429                        code_language: None,
1430                        blockquote_depth,
1431                        line_height: None,
1432                        non_breakable_lines: None,
1433                        page_break_before: None,
1434                        direction: None,
1435                        background_color: None,
1436                        alignment: None,
1437                        top_margin: None,
1438                        text_indent: None,
1439                        semantic_role: None,
1440                    }));
1441                }
1442            }
1443            _ => {
1444                // Document, Comment, etc. — recurse children
1445                for child in node.children() {
1446                    walk_node(
1447                        child,
1448                        state,
1449                        elements,
1450                        current_list_style,
1451                        blockquote_depth,
1452                        list_depth,
1453                        depth + 1,
1454                    );
1455                }
1456            }
1457        }
1458    }
1459
1460    /// Collect inline spans from a block-level element's children.
1461    /// If a nested block-level element is encountered, it is flushed as a
1462    /// separate block.
1463    #[allow(clippy::too_many_arguments)]
1464    fn collect_inline_spans(
1465        node: ego_tree::NodeRef<Node>,
1466        state: &FmtState,
1467        spans: &mut Vec<ParsedSpan>,
1468        current_list_style: &Option<ListStyle>,
1469        elements: &mut Vec<ParsedElement>,
1470        blockquote_depth: u32,
1471        list_depth: u32,
1472        depth: usize,
1473    ) {
1474        if depth > MAX_RECURSION_DEPTH {
1475            return;
1476        }
1477        for child in node.children() {
1478            match child.value() {
1479                Node::Text(text) => {
1480                    let t = text.text.to_string();
1481                    if !t.is_empty() {
1482                        spans.push(ParsedSpan {
1483                            text: t,
1484                            bold: state.bold,
1485                            italic: state.italic,
1486                            underline: state.underline,
1487                            strikeout: state.strikeout,
1488                            code: state.code,
1489                            superscript: false,
1490                            subscript: false,
1491                            link_href: state.link_href.clone(),
1492                            image: None,
1493                            footnote_ref: None,
1494                        });
1495                    }
1496                }
1497                Node::Element(el) => {
1498                    let tag = el.name();
1499                    let mut new_state = state.clone();
1500
1501                    match tag {
1502                        "b" | "strong" => new_state.bold = true,
1503                        "i" | "em" => new_state.italic = true,
1504                        "u" | "ins" => new_state.underline = true,
1505                        "s" | "del" | "strike" => new_state.strikeout = true,
1506                        "code" => new_state.code = true,
1507                        "a" => {
1508                            if let Some(href) = el.attr("href") {
1509                                new_state.link_href = Some(href.to_string());
1510                            }
1511                        }
1512                        "img" => {
1513                            if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1514                                spans.push(span);
1515                            }
1516                            continue;
1517                        }
1518                        _ => {}
1519                    }
1520
1521                    // Check for nested block elements
1522                    let nested_block = matches!(
1523                        tag,
1524                        "p" | "div"
1525                            | "h1"
1526                            | "h2"
1527                            | "h3"
1528                            | "h4"
1529                            | "h5"
1530                            | "h6"
1531                            | "li"
1532                            | "pre"
1533                            | "blockquote"
1534                            | "ul"
1535                            | "ol"
1536                    );
1537
1538                    if tag == "br" {
1539                        // br within a block: treat as splitting into new block
1540                        // For simplicity, just add a newline to current span
1541                        spans.push(ParsedSpan {
1542                            text: String::new(),
1543                            ..Default::default()
1544                        });
1545                    } else if nested_block || tag == "table" {
1546                        // Flush as separate element
1547                        walk_node(
1548                            child,
1549                            &new_state,
1550                            elements,
1551                            current_list_style,
1552                            blockquote_depth,
1553                            list_depth,
1554                            depth + 1,
1555                        );
1556                    } else {
1557                        // Inline element: recurse
1558                        collect_inline_spans(
1559                            child,
1560                            &new_state,
1561                            spans,
1562                            current_list_style,
1563                            elements,
1564                            blockquote_depth,
1565                            list_depth,
1566                            depth + 1,
1567                        );
1568                    }
1569                }
1570                _ => {}
1571            }
1572        }
1573    }
1574
1575    let initial_state = FmtState::default();
1576    // Treat the root element as a block-level container so that
1577    // top-level inline elements (e.g. `<b>Bold</b> <em>Italic</em>`)
1578    // are grouped into a single block instead of becoming separate blocks.
1579    let mut root_spans: Vec<ParsedSpan> = Vec::new();
1580    collect_inline_spans(
1581        *root,
1582        &initial_state,
1583        &mut root_spans,
1584        &None,
1585        &mut elements,
1586        0,
1587        0,
1588        0,
1589    );
1590    if !root_spans.is_empty() {
1591        elements.push(ParsedElement::Block(ParsedBlock {
1592            spans: root_spans,
1593            heading_level: None,
1594            list_style: None,
1595            list_indent: 0,
1596            list_prefix: String::new(),
1597            list_suffix: String::new(),
1598            marker: None,
1599            is_code_block: false,
1600            code_language: None,
1601            blockquote_depth: 0,
1602            line_height: None,
1603            non_breakable_lines: None,
1604            page_break_before: None,
1605            direction: None,
1606            background_color: None,
1607            alignment: None,
1608            top_margin: None,
1609            text_indent: None,
1610            semantic_role: None,
1611        }));
1612    }
1613
1614    // If no elements were parsed, create a single empty paragraph
1615    if elements.is_empty() {
1616        elements.push(ParsedElement::Block(ParsedBlock {
1617            spans: vec![ParsedSpan {
1618                text: String::new(),
1619                ..Default::default()
1620            }],
1621            heading_level: None,
1622            list_style: None,
1623            list_indent: 0,
1624            list_prefix: String::new(),
1625            list_suffix: String::new(),
1626            marker: None,
1627            is_code_block: false,
1628            code_language: None,
1629            blockquote_depth: 0,
1630            line_height: None,
1631            non_breakable_lines: None,
1632            page_break_before: None,
1633            direction: None,
1634            background_color: None,
1635            alignment: None,
1636            top_margin: None,
1637            text_indent: None,
1638            semantic_role: None,
1639        }));
1640    }
1641
1642    elements
1643}
1644
1645/// Convert a `ParsedSpan` (parser output) into the `CharacterFormat` used by
1646/// `FormatRun`. `is_code_block` forces `monospace` as the font family for
1647/// every span inside a code block.
1648pub fn character_format_from_span(
1649    span: &ParsedSpan,
1650    is_code_block: bool,
1651) -> crate::format_runs::CharacterFormat {
1652    use crate::entities::CharVerticalAlignment;
1653    crate::format_runs::CharacterFormat {
1654        font_bold: if span.bold { Some(true) } else { None },
1655        font_italic: if span.italic { Some(true) } else { None },
1656        font_underline: if span.underline { Some(true) } else { None },
1657        font_strikeout: if span.strikeout { Some(true) } else { None },
1658        font_family: if span.code || is_code_block {
1659            Some("monospace".to_string())
1660        } else {
1661            None
1662        },
1663        anchor_href: span.link_href.clone(),
1664        is_anchor: if span.link_href.is_some() {
1665            Some(true)
1666        } else {
1667            None
1668        },
1669        vertical_alignment: if span.superscript {
1670            Some(CharVerticalAlignment::SuperScript)
1671        } else if span.subscript {
1672            Some(CharVerticalAlignment::SubScript)
1673        } else {
1674            None
1675        },
1676        ..Default::default()
1677    }
1678}
1679
1680/// Translate a slice of parsed spans into `(plain_text, format_runs)`.
1681///
1682/// One non-default span yields one `FormatRun`; spans with empty
1683/// `CharacterFormat` (no decoration, no link, no code) emit no run, since an
1684/// absent run means "inherit default formatting" in the new model. Adjacent
1685/// runs with identical formats are coalesced via `coalesce_in_place` so the
1686/// resulting vector satisfies `debug_assert_well_formed`.
1687///
1688/// Returns the concatenated `plain_text` of all spans and a sorted,
1689/// non-overlapping, coalesced `Vec<FormatRun>`. Both safe to feed straight
1690/// into the store under the dual-write bridge.
1691pub fn format_runs_from_spans(spans: &[ParsedSpan], is_code_block: bool) -> ParsedInline {
1692    use crate::format_runs::{
1693        CharacterFormat, FootnoteRefAnchor, FormatRun, ImageAnchor, coalesce_in_place,
1694    };
1695
1696    let mut plain_text = String::new();
1697    let mut runs: Vec<FormatRun> = Vec::new();
1698    let mut images: Vec<ImageAnchor> = Vec::new();
1699    let mut footnote_refs: Vec<FootnoteRefAnchor> = Vec::new();
1700    let default = CharacterFormat::default();
1701
1702    for span in spans {
1703        let byte_start = plain_text.len() as u32;
1704
1705        if let Some(label) = &span.footnote_ref {
1706            // A reference occupies one U+FFFC, exactly as an image does, so
1707            // every downstream offset treats the two alike.
1708            plain_text.push('\u{FFFC}');
1709            // Raised, always — whatever the surrounding run is doing.
1710            //
1711            // A footnote marker is superscript by definition, in every
1712            // typographic tradition and in every reader that renders djot. The
1713            // ambient `superscript` flag the span carries is the *prose's*, and
1714            // prose is not superscript, so taking it verbatim sets a note's
1715            // number on the baseline in the middle of a sentence — which reads
1716            // as a stray digit the writer typed rather than as a reference.
1717            //
1718            // It is set here, on the anchor, rather than at render time so that
1719            // every consumer agrees: the editor raises it, the exporters that
1720            // carry character formatting carry it, and the djot writer knows to
1721            // emit `[^label]` *without* wrapping it in `^…^` (see
1722            // `a_reference_is_not_wrapped_in_superscript_markers`).
1723            let mut format = character_format_from_span(span, is_code_block);
1724            format.vertical_alignment = Some(crate::entities::CharVerticalAlignment::SuperScript);
1725            footnote_refs.push(FootnoteRefAnchor {
1726                byte_offset: byte_start,
1727                label: label.clone(),
1728                format,
1729            });
1730            continue;
1731        }
1732
1733        if let Some(image) = &span.image {
1734            // An image occupies one U+FFFC in the text, exactly as
1735            // `insert_image` mirrors into the rope, so every downstream offset
1736            // calculation treats a parsed image and an inserted one alike.
1737            plain_text.push('\u{FFFC}');
1738            images.push(ImageAnchor {
1739                byte_offset: byte_start,
1740                name: image.src.clone(),
1741                alt: image.alt.clone(),
1742                width: image.width,
1743                height: image.height,
1744                quality: 100,
1745                format: character_format_from_span(span, is_code_block),
1746            });
1747            continue;
1748        }
1749
1750        plain_text.push_str(&span.text);
1751        let byte_end = plain_text.len() as u32;
1752        if byte_start == byte_end {
1753            continue;
1754        }
1755        let format = character_format_from_span(span, is_code_block);
1756        if format == default {
1757            continue;
1758        }
1759        runs.push(FormatRun {
1760            byte_start,
1761            byte_end,
1762            format,
1763        });
1764    }
1765    coalesce_in_place(&mut runs);
1766    ParsedInline {
1767        plain_text,
1768        runs,
1769        images,
1770        footnote_refs,
1771    }
1772}
1773
1774/// The three parallel things a block stores, as recovered from parsed spans.
1775///
1776/// Returned as a struct rather than a tuple because it grew a third member
1777/// (images) after nine call sites already destructured a pair — and every one
1778/// of those sites has to decide what to do with images, so a silent
1779/// tuple-arity change would have been the wrong kind of easy.
1780#[derive(Debug, Clone, Default)]
1781pub struct ParsedInline {
1782    pub plain_text: String,
1783    pub runs: Vec<crate::format_runs::FormatRun>,
1784    pub images: Vec<crate::format_runs::ImageAnchor>,
1785    pub footnote_refs: Vec<crate::format_runs::FootnoteRefAnchor>,
1786}
1787
1788// ─── Djot parsing ────────────────────────────────────────────────────
1789
1790/// Map a jotdown unordered/task bullet marker to a model `ListStyle`.
1791///
1792/// The mapping is a stable bijection (`-`↔Disc, `*`↔Circle, `+`↔Square) so the
1793/// djot exporter can recover the exact bullet character for a lossless
1794/// round-trip.
1795fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
1796    use jotdown::ListBulletType as B;
1797    match b {
1798        B::Dash => ListStyle::Disc,
1799        B::Star => ListStyle::Circle,
1800        B::Plus => ListStyle::Square,
1801    }
1802}
1803
1804/// Map a jotdown ordered-list numbering scheme to a model `ListStyle`.
1805fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
1806    use jotdown::OrderedListNumbering as N;
1807    match n {
1808        N::Decimal => ListStyle::Decimal,
1809        N::AlphaLower => ListStyle::LowerAlpha,
1810        N::AlphaUpper => ListStyle::UpperAlpha,
1811        N::RomanLower => ListStyle::LowerRoman,
1812        N::RomanUpper => ListStyle::UpperRoman,
1813    }
1814}
1815
1816/// Map a jotdown ordered-list delimiter to the `(prefix, suffix)` affixes
1817/// stored on the `List` entity (`1.` → `("", ".")`, `1)` → `("", ")")`,
1818/// `(1)` → `("(", ")")`).
1819fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
1820    use jotdown::OrderedListStyle as S;
1821    match style {
1822        S::Period => (String::new(), ".".to_string()),
1823        S::Paren => (String::new(), ")".to_string()),
1824        S::ParenParen => ("(".to_string(), ")".to_string()),
1825    }
1826}
1827
1828/// Optional block-level style attributes carried on a djot block through its
1829/// `{key=value}` block attributes. All `None` when the block has no such
1830/// attributes (or they were filtered out by [`DjotImportOptions`]).
1831#[derive(Debug, Clone, Default)]
1832struct DjotBlockStyle {
1833    alignment: Option<Alignment>,
1834    line_height: Option<i64>,
1835    non_breakable_lines: Option<bool>,
1836    page_break_before: Option<bool>,
1837    direction: Option<TextDirection>,
1838    background_color: Option<String>,
1839    top_margin: Option<i64>,
1840    text_indent: Option<i64>,
1841    semantic_role: Option<SemanticRole>,
1842}
1843
1844impl DjotBlockStyle {
1845    /// Overlay the `Some` fields of `other` onto `self`, leaving `self`'s
1846    /// existing values for any field `other` does not set. Used to combine a
1847    /// heading's enclosing-`Section` attributes with any on the heading itself.
1848    fn merge_from(&mut self, other: DjotBlockStyle) {
1849        if other.alignment.is_some() {
1850            self.alignment = other.alignment;
1851        }
1852        if other.line_height.is_some() {
1853            self.line_height = other.line_height;
1854        }
1855        if other.non_breakable_lines.is_some() {
1856            self.non_breakable_lines = other.non_breakable_lines;
1857        }
1858        if other.page_break_before.is_some() {
1859            self.page_break_before = other.page_break_before;
1860        }
1861        if other.direction.is_some() {
1862            self.direction = other.direction;
1863        }
1864        if other.background_color.is_some() {
1865            self.background_color = other.background_color;
1866        }
1867        if other.top_margin.is_some() {
1868            self.top_margin = other.top_margin;
1869        }
1870        if other.text_indent.is_some() {
1871            self.text_indent = other.text_indent;
1872        }
1873        if other.semantic_role.is_some() {
1874            self.semantic_role = other.semantic_role.clone();
1875        }
1876    }
1877}
1878
1879/// Read the round-tripped block-style attributes off a djot block's
1880/// [`jotdown::Attributes`], honouring the import [`DjotImportOptions`]. Keys are
1881/// the model field names (`alignment`, `line_height`, `direction`,
1882/// `non_breakable_lines`, `page_break_before`, `background_color`, `top_margin`,
1883/// `text_indent`, `semantic_role`); unrecognised values are ignored.
1884fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
1885    let mut style = DjotBlockStyle::default();
1886
1887    if opts.alignment
1888        && let Some(v) = attrs.get_value("alignment")
1889    {
1890        style.alignment = match v.to_string().as_str() {
1891            "left" => Some(Alignment::Left),
1892            "right" => Some(Alignment::Right),
1893            "center" => Some(Alignment::Center),
1894            "justify" => Some(Alignment::Justify),
1895            _ => None,
1896        };
1897    }
1898    if opts.line_height
1899        && let Some(v) = attrs.get_value("line_height")
1900    {
1901        style.line_height = v.to_string().parse::<i64>().ok();
1902    }
1903    if opts.direction
1904        && let Some(v) = attrs.get_value("direction")
1905    {
1906        style.direction = match v.to_string().as_str() {
1907            "ltr" => Some(TextDirection::LeftToRight),
1908            "rtl" => Some(TextDirection::RightToLeft),
1909            _ => None,
1910        };
1911    }
1912    if opts.non_breakable_lines
1913        && let Some(v) = attrs.get_value("non_breakable_lines")
1914    {
1915        style.non_breakable_lines = match v.to_string().as_str() {
1916            "true" => Some(true),
1917            "false" => Some(false),
1918            _ => None,
1919        };
1920    }
1921    if opts.page_break_before
1922        && let Some(v) = attrs.get_value("page_break_before")
1923    {
1924        style.page_break_before = match v.to_string().as_str() {
1925            "true" => Some(true),
1926            "false" => Some(false),
1927            _ => None,
1928        };
1929    }
1930    if opts.background_color
1931        && let Some(v) = attrs.get_value("background_color")
1932    {
1933        style.background_color = Some(v.to_string());
1934    }
1935    if opts.top_margin
1936        && let Some(v) = attrs.get_value("top_margin")
1937    {
1938        style.top_margin = v.to_string().parse::<i64>().ok();
1939    }
1940    if opts.text_indent
1941        && let Some(v) = attrs.get_value("text_indent")
1942    {
1943        style.text_indent = v.to_string().parse::<i64>().ok();
1944    }
1945    if opts.semantic_role
1946        && let Some(v) = attrs.get_value("semantic_role")
1947    {
1948        style.semantic_role = match v.to_string().as_str() {
1949            "epigraph" => Some(SemanticRole::Epigraph),
1950            // An unknown role is dropped, not guessed at — the same way an unknown
1951            // alignment value above is. A future role read by an older build then
1952            // degrades to a plain blockquote, which is what it looks like anyway.
1953            _ => None,
1954        };
1955    }
1956
1957    style
1958}
1959
1960/// Push a finished block into `elements`, applying the djot block-level fields
1961/// plus any round-tripped block-style attributes carried in `style`.
1962#[allow(clippy::too_many_arguments)]
1963fn djot_push_block(
1964    elements: &mut Vec<ParsedElement>,
1965    spans: Vec<ParsedSpan>,
1966    heading_level: Option<i64>,
1967    list_style: Option<ListStyle>,
1968    list_indent: u32,
1969    list_prefix: String,
1970    list_suffix: String,
1971    marker: Option<MarkerType>,
1972    is_code_block: bool,
1973    code_language: Option<String>,
1974    blockquote_depth: u32,
1975    style: DjotBlockStyle,
1976) {
1977    elements.push(ParsedElement::Block(ParsedBlock {
1978        spans,
1979        heading_level,
1980        list_style,
1981        list_indent,
1982        list_prefix,
1983        list_suffix,
1984        marker,
1985        is_code_block,
1986        code_language,
1987        blockquote_depth,
1988        line_height: style.line_height,
1989        non_breakable_lines: style.non_breakable_lines,
1990        page_break_before: style.page_break_before,
1991        direction: style.direction,
1992        background_color: style.background_color,
1993        alignment: style.alignment,
1994        top_margin: style.top_margin,
1995        text_indent: style.text_indent,
1996        semantic_role: style.semantic_role.clone(),
1997    }));
1998}
1999
2000/// Parse djot source into the shared [`ParsedElement`] intermediate, mirroring
2001/// [`parse_markdown`]. Uses the [`jotdown`] pull parser.
2002///
2003/// Constructs the document model cannot represent are dropped, and their text
2004/// content is discarded so it never leaks into the document: footnotes, math,
2005/// fenced divs, raw blocks/inline, thematic breaks, description lists,
2006/// captions, symbols, link-reference definitions, and highlight/`mark`. Inline
2007/// images keep their alt text as plain text (the image itself is not modelled),
2008/// matching the Markdown importer. Smart-punctuation events are normalised to
2009/// their canonical Unicode characters so the model→djot→model round-trip is a
2010/// fixpoint.
2011///
2012/// Standalone paragraphs and headings additionally carry the optional
2013/// block-style attributes selected by `options` — paragraph alignment, line
2014/// height, text direction, non-breakable lines and background color — read from
2015/// djot `{key=value}` block attributes (see [`DjotImportOptions`]). List items,
2016/// code blocks and table cells normalise their block styling away.
2017///
2018/// Known model limitations (normalised, not preserved on round-trip):
2019/// ordered-list start number, table column alignment, and list tight/loose.
2020pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
2021    use jotdown::{Container as C, Event as E, ListKind, Parser};
2022
2023    let mut elements: Vec<ParsedElement> = Vec::new();
2024    let mut current_spans: Vec<ParsedSpan> = Vec::new();
2025    let mut current_heading: Option<i64> = None;
2026    let mut is_code_block = false;
2027    let mut code_language: Option<String> = None;
2028    let mut blockquote_depth: u32 = 0;
2029    // Block-style attributes captured from a standalone paragraph/heading's djot
2030    // `{…}` block attributes, consumed when that block is flushed.
2031    let mut pending_style = DjotBlockStyle::default();
2032
2033    // Inline formatting state.
2034    let mut bold = false;
2035    let mut italic = false;
2036    let mut underline = false;
2037    let mut strikeout = false;
2038    let mut code = false;
2039    let mut superscript = false;
2040    let mut subscript = false;
2041    let mut link_href: Option<String> = None;
2042    // Set between an image's Start and End. Its alt text arrives as ordinary
2043    // `Str` events in between, so it has to be captured rather than emitted.
2044    let mut pending_image: Option<ParsedImage> = None;
2045
2046    // List nesting: each entry is (style, prefix, suffix); depth = indent + 1.
2047    let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
2048    // Context applied to the next flushed block while inside a list item.
2049    let mut cur_list_style: Option<ListStyle> = None;
2050    let mut cur_list_prefix = String::new();
2051    let mut cur_list_suffix = String::new();
2052    let mut cur_list_indent: u32 = 0;
2053    let mut cur_marker: Option<MarkerType> = None;
2054
2055    // Table accumulation.
2056    let mut in_table_cell = false;
2057    let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
2058    let mut current_row: Vec<ParsedTableCell> = Vec::new();
2059    let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
2060    let mut table_header_rows: usize = 0;
2061    let mut row_is_head = false;
2062
2063    // Subtree-skip depth for unrepresentable containers (their entire content
2064    // is dropped). Incremented on the dropped container's `Start` and on every
2065    // nested `Start`; decremented on every `End`.
2066    let mut skip_depth: u32 = 0;
2067
2068    // The label and element index of the footnote definition currently open,
2069    // if any. Djot has no footnote inside a footnote, so one slot is enough
2070    // where the dropped containers need a depth counter.
2071    let mut footnote_open: Option<(String, usize)> = None;
2072
2073    // Push one inline span carrying the current formatting state into the
2074    // active sink (table cell or block). A macro (not a closure) to avoid
2075    // borrowing `current_spans`/`current_cell_spans` across the formatting
2076    // state reads.
2077    macro_rules! push_text {
2078        ($t:expr) => {{
2079            // Alt text belongs to the image, not to the paragraph. While an
2080            // image is open every text event is diverted into its description,
2081            // which is what keeps a photo's caption out of the manuscript's
2082            // word count and out of the search corpus.
2083            if let Some(img) = pending_image.as_mut() {
2084                img.alt.push_str(($t).as_ref());
2085            } else {
2086                let sp = ParsedSpan {
2087                    text: ($t).to_string(),
2088                    bold,
2089                    italic,
2090                    underline,
2091                    strikeout,
2092                    code,
2093                    superscript,
2094                    subscript,
2095                    link_href: link_href.clone(),
2096                    image: None,
2097                    footnote_ref: None,
2098                };
2099                if in_table_cell {
2100                    current_cell_spans.push(sp);
2101                } else {
2102                    current_spans.push(sp);
2103                }
2104            }
2105        }};
2106    }
2107
2108    // Push a completed inline image span into the active sink.
2109    macro_rules! push_image {
2110        ($img:expr) => {{
2111            let sp = ParsedSpan {
2112                text: String::new(),
2113                bold,
2114                italic,
2115                underline,
2116                strikeout,
2117                code,
2118                superscript,
2119                subscript,
2120                link_href: link_href.clone(),
2121                image: Some($img),
2122                footnote_ref: None,
2123            };
2124            if in_table_cell {
2125                current_cell_spans.push(sp);
2126            } else {
2127                current_spans.push(sp);
2128            }
2129        }};
2130    }
2131
2132    // Enter a list item, flushing any unterminated inline content first and
2133    // capturing the list context + task marker for the item's block.
2134    macro_rules! enter_item {
2135        ($marker:expr) => {{
2136            if !current_spans.is_empty() {
2137                djot_push_block(
2138                    &mut elements,
2139                    std::mem::take(&mut current_spans),
2140                    None,
2141                    cur_list_style.clone(),
2142                    cur_list_indent,
2143                    cur_list_prefix.clone(),
2144                    cur_list_suffix.clone(),
2145                    cur_marker.clone(),
2146                    false,
2147                    None,
2148                    blockquote_depth,
2149                    DjotBlockStyle::default(),
2150                );
2151            }
2152            let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
2153                ListStyle::Disc,
2154                String::new(),
2155                String::new(),
2156            ));
2157            cur_list_style = Some(style);
2158            cur_list_prefix = prefix;
2159            cur_list_suffix = suffix;
2160            cur_list_indent = list_stack.len().saturating_sub(1) as u32;
2161            cur_marker = $marker;
2162        }};
2163    }
2164
2165    for event in Parser::new(djot) {
2166        if skip_depth > 0 {
2167            match event {
2168                E::Start(..) => skip_depth += 1,
2169                E::End(_) => skip_depth -= 1,
2170                _ => {}
2171            }
2172            continue;
2173        }
2174
2175        match event {
2176            // ── Transparent wrappers (unwrap, keep content) ──
2177            E::Start(C::Document, _) | E::End(C::Document) => {}
2178            E::Start(C::Section { .. }, attrs) => {
2179                // A heading's block attributes attach to its enclosing Section,
2180                // not the heading itself; capture them for the heading's flush.
2181                if list_stack.is_empty() {
2182                    pending_style.merge_from(block_attrs_to_style(&attrs, options));
2183                }
2184            }
2185            E::End(C::Section { .. }) => {}
2186            E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
2187
2188            // ── Blockquote ──
2189            E::Start(C::Blockquote, _) => blockquote_depth += 1,
2190            E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
2191
2192            // ── Lists ──
2193            E::Start(C::List { kind, .. }, _) => {
2194                let (style, prefix, suffix) = match kind {
2195                    ListKind::Unordered(b) | ListKind::Task(b) => {
2196                        (djot_bullet_style(b), String::new(), String::new())
2197                    }
2198                    ListKind::Ordered {
2199                        numbering, style, ..
2200                    } => {
2201                        let (p, s) = djot_ordered_affixes(style);
2202                        (djot_ordered_style(numbering), p, s)
2203                    }
2204                };
2205                list_stack.push((style, prefix, suffix));
2206            }
2207            E::End(C::List { .. }) => {
2208                list_stack.pop();
2209                cur_list_style = None;
2210                cur_marker = None;
2211            }
2212            E::Start(C::ListItem, _) => enter_item!(None),
2213            E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
2214                MarkerType::Checked
2215            } else {
2216                MarkerType::Unchecked
2217            })),
2218            E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
2219                // Tight item without a wrapping paragraph (defensive flush).
2220                if !current_spans.is_empty() {
2221                    djot_push_block(
2222                        &mut elements,
2223                        std::mem::take(&mut current_spans),
2224                        None,
2225                        cur_list_style.clone(),
2226                        cur_list_indent,
2227                        cur_list_prefix.clone(),
2228                        cur_list_suffix.clone(),
2229                        cur_marker.clone(),
2230                        false,
2231                        None,
2232                        blockquote_depth,
2233                        DjotBlockStyle::default(),
2234                    );
2235                }
2236                cur_list_style = None;
2237                cur_marker = None;
2238            }
2239
2240            // ── Headings, paragraphs, code blocks ──
2241            E::Start(C::Heading { level, .. }, attrs) => {
2242                current_heading = Some(level as i64);
2243                // The block-style attributes live on the enclosing Section;
2244                // merge any placed directly on the heading without clearing them.
2245                pending_style.merge_from(block_attrs_to_style(&attrs, options));
2246            }
2247            E::End(C::Heading { .. }) => {
2248                djot_push_block(
2249                    &mut elements,
2250                    std::mem::take(&mut current_spans),
2251                    current_heading.take(),
2252                    None,
2253                    0,
2254                    String::new(),
2255                    String::new(),
2256                    None,
2257                    false,
2258                    None,
2259                    blockquote_depth,
2260                    std::mem::take(&mut pending_style),
2261                );
2262            }
2263            E::Start(C::Paragraph, attrs) => {
2264                current_heading = None;
2265                // Block attributes only apply to standalone paragraphs;
2266                // list-item paragraphs normalise their styling away (matching
2267                // the exporter).
2268                pending_style = if list_stack.is_empty() {
2269                    block_attrs_to_style(&attrs, options)
2270                } else {
2271                    DjotBlockStyle::default()
2272                };
2273            }
2274            E::End(C::Paragraph) => {
2275                if !current_spans.is_empty() {
2276                    djot_push_block(
2277                        &mut elements,
2278                        std::mem::take(&mut current_spans),
2279                        None,
2280                        cur_list_style.clone(),
2281                        cur_list_indent,
2282                        cur_list_prefix.clone(),
2283                        cur_list_suffix.clone(),
2284                        cur_marker.clone(),
2285                        false,
2286                        None,
2287                        blockquote_depth,
2288                        std::mem::take(&mut pending_style),
2289                    );
2290                }
2291                cur_list_style = None;
2292                cur_marker = None;
2293            }
2294            E::Start(C::CodeBlock { language }, _) => {
2295                is_code_block = true;
2296                code_language = if language.is_empty() {
2297                    None
2298                } else {
2299                    Some(language.to_string())
2300                };
2301            }
2302            E::End(C::CodeBlock { .. }) => {
2303                // Strip the single trailing newline jotdown appends.
2304                if let Some(last) = current_spans.last_mut()
2305                    && last.text.ends_with('\n')
2306                {
2307                    last.text.pop();
2308                }
2309                djot_push_block(
2310                    &mut elements,
2311                    std::mem::take(&mut current_spans),
2312                    None,
2313                    None,
2314                    0,
2315                    String::new(),
2316                    String::new(),
2317                    None,
2318                    true,
2319                    code_language.take(),
2320                    blockquote_depth,
2321                    DjotBlockStyle::default(),
2322                );
2323                is_code_block = false;
2324            }
2325
2326            // ── Tables ──
2327            E::Start(C::Table, _) => {
2328                table_rows.clear();
2329                current_row.clear();
2330                current_cell_spans.clear();
2331                table_header_rows = 0;
2332            }
2333            E::End(C::Table) => {
2334                elements.push(ParsedElement::Table(ParsedTable {
2335                    header_rows: table_header_rows,
2336                    rows: std::mem::take(&mut table_rows),
2337                    blockquote_depth,
2338                }));
2339            }
2340            E::Start(C::TableRow { head }, _) => {
2341                row_is_head = head;
2342                current_row.clear();
2343            }
2344            E::End(C::TableRow { .. }) => {
2345                if row_is_head {
2346                    table_header_rows += 1;
2347                }
2348                table_rows.push(std::mem::take(&mut current_row));
2349            }
2350            E::Start(C::TableCell { .. }, _) => {
2351                in_table_cell = true;
2352                current_cell_spans.clear();
2353            }
2354            E::End(C::TableCell { .. }) => {
2355                in_table_cell = false;
2356                current_row.push(ParsedTableCell {
2357                    spans: std::mem::take(&mut current_cell_spans),
2358                });
2359            }
2360
2361            // ── Inline formatting ──
2362            E::Start(C::Strong, _) => bold = true,
2363            E::End(C::Strong) => bold = false,
2364            E::Start(C::Emphasis, _) => italic = true,
2365            E::End(C::Emphasis) => italic = false,
2366            E::Start(C::Verbatim, _) => code = true,
2367            E::End(C::Verbatim) => code = false,
2368            E::Start(C::Superscript, _) => superscript = true,
2369            E::End(C::Superscript) => superscript = false,
2370            E::Start(C::Subscript, _) => subscript = true,
2371            E::End(C::Subscript) => subscript = false,
2372            E::Start(C::Insert, _) => underline = true,
2373            E::End(C::Insert) => underline = false,
2374            E::Start(C::Delete, _) => strikeout = true,
2375            E::End(C::Delete) => strikeout = false,
2376            // Highlight/mark and bare spans have no model field — keep the text.
2377            E::Start(C::Mark, _) | E::End(C::Mark) => {}
2378            E::Start(C::Span, _) | E::End(C::Span) => {}
2379            E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
2380            E::End(C::Link(..)) => link_href = None,
2381            // Inline images. Djot writes display size as inline attributes
2382            // (`![alt](src){width=800 height=600}`), which jotdown hands over
2383            // on the `Start` event — verified against jotdown 0.10, including
2384            // quoted values and images mid-sentence.
2385            E::Start(C::Image(src, _), attrs) => {
2386                let attr_num = |key: &str| -> i64 {
2387                    attrs
2388                        .get_value(key)
2389                        .map(|v| v.to_string())
2390                        .and_then(|v| v.trim().parse::<i64>().ok())
2391                        .filter(|n| *n > 0)
2392                        .unwrap_or(0)
2393                };
2394                pending_image = Some(ParsedImage {
2395                    src: src.to_string(),
2396                    alt: String::new(),
2397                    width: attr_num("width"),
2398                    height: attr_num("height"),
2399                });
2400            }
2401            E::End(C::Image(..)) => {
2402                if let Some(img) = pending_image.take() {
2403                    push_image!(img);
2404                }
2405            }
2406
2407            // ── Footnote definitions ──
2408            //
2409            // The body is ordinary block content, so it is parsed by the
2410            // ordinary machinery: flush whatever inline run is open, note where
2411            // this definition's blocks start, and let them accumulate. `End`
2412            // lifts them back out. Nesting cannot occur — djot has no footnote
2413            // inside a footnote — so one mark suffices where the dropped
2414            // containers below need a depth counter.
2415            E::Start(C::Footnote { label }, _) => {
2416                if !current_spans.is_empty() {
2417                    djot_push_block(
2418                        &mut elements,
2419                        std::mem::take(&mut current_spans),
2420                        None,
2421                        cur_list_style.clone(),
2422                        cur_list_indent,
2423                        cur_list_prefix.clone(),
2424                        cur_list_suffix.clone(),
2425                        cur_marker.clone(),
2426                        false,
2427                        None,
2428                        blockquote_depth,
2429                        DjotBlockStyle::default(),
2430                    );
2431                }
2432                footnote_open = Some((label.to_string(), elements.len()));
2433            }
2434            E::End(C::Footnote { .. }) => {
2435                if !current_spans.is_empty() {
2436                    djot_push_block(
2437                        &mut elements,
2438                        std::mem::take(&mut current_spans),
2439                        None,
2440                        cur_list_style.clone(),
2441                        cur_list_indent,
2442                        cur_list_prefix.clone(),
2443                        cur_list_suffix.clone(),
2444                        cur_marker.clone(),
2445                        false,
2446                        None,
2447                        blockquote_depth,
2448                        DjotBlockStyle::default(),
2449                    );
2450                }
2451                if let Some((label, start)) = footnote_open.take() {
2452                    let blocks: Vec<ParsedBlock> = elements
2453                        .drain(start..)
2454                        .filter_map(|e| match e {
2455                            ParsedElement::Block(b) => Some(b),
2456                            // A table inside a footnote is not representable as
2457                            // note content; its cells would have to become
2458                            // blocks and lose their structure either way.
2459                            _ => None,
2460                        })
2461                        .collect();
2462                    elements.push(ParsedElement::FootnoteDefinition { label, blocks });
2463                }
2464            }
2465
2466            // ── Unrepresentable containers: drop the entire subtree ──
2467            E::Start(
2468                C::Math { .. }
2469                | C::RawBlock { .. }
2470                | C::RawInline { .. }
2471                | C::DescriptionList
2472                | C::DescriptionDetails
2473                | C::DescriptionTerm
2474                | C::Caption
2475                | C::LinkDefinition { .. },
2476                _,
2477            ) => skip_depth = 1,
2478
2479            // ── Text + atoms ──
2480            E::Str(s) => push_text!(s.as_ref()),
2481            E::Softbreak => push_text!(" "),
2482            E::LeftSingleQuote => push_text!("\u{2018}"),
2483            E::RightSingleQuote => push_text!("\u{2019}"),
2484            E::LeftDoubleQuote => push_text!("\u{201C}"),
2485            E::RightDoubleQuote => push_text!("\u{201D}"),
2486            E::Ellipsis => push_text!("\u{2026}"),
2487            E::EnDash => push_text!("\u{2013}"),
2488            E::EmDash => push_text!("\u{2014}"),
2489            E::NonBreakingSpace => push_text!("\u{00A0}"),
2490            E::Hardbreak => {
2491                if in_table_cell {
2492                    push_text!(" ");
2493                } else if !current_spans.is_empty() {
2494                    // Mirrors the Markdown importer: a hard break splits the
2495                    // paragraph into a new block.
2496                    djot_push_block(
2497                        &mut elements,
2498                        std::mem::take(&mut current_spans),
2499                        None,
2500                        cur_list_style.clone(),
2501                        cur_list_indent,
2502                        cur_list_prefix.clone(),
2503                        cur_list_suffix.clone(),
2504                        cur_marker.clone(),
2505                        is_code_block,
2506                        code_language.clone(),
2507                        blockquote_depth,
2508                        pending_style.clone(),
2509                    );
2510                }
2511            }
2512            // A footnote reference. jotdown emits this purely syntactically —
2513            // it never checks that a matching `[^label]:` exists anywhere — so a
2514            // reference whose definition lives outside this document (the normal
2515            // state for a host that owns note bodies itself) arrives here just
2516            // the same, and must survive.
2517            E::FootnoteReference(label) => {
2518                let sp = ParsedSpan {
2519                    text: String::new(),
2520                    bold,
2521                    italic,
2522                    underline,
2523                    strikeout,
2524                    code,
2525                    superscript,
2526                    subscript,
2527                    link_href: link_href.clone(),
2528                    image: None,
2529                    footnote_ref: Some(label.to_string()),
2530                };
2531                if in_table_cell {
2532                    current_cell_spans.push(sp);
2533                } else {
2534                    current_spans.push(sp);
2535                }
2536            }
2537            // Symbols, escapes, blanklines, thematic breaks and dangling block
2538            // attributes carry no representable content.
2539            E::Symbol(_) => {}
2540            E::Escape | E::Blankline => {}
2541            E::ThematicBreak(_) | E::Attributes(_) => {}
2542
2543            // Ends of dropped containers (never reached at skip_depth 0) and any
2544            // future variants.
2545            _ => {}
2546        }
2547    }
2548
2549    // Flush any trailing inline content (defensive — Document End closes blocks).
2550    if !current_spans.is_empty() {
2551        djot_push_block(
2552            &mut elements,
2553            std::mem::take(&mut current_spans),
2554            current_heading.take(),
2555            cur_list_style.clone(),
2556            cur_list_indent,
2557            cur_list_prefix.clone(),
2558            cur_list_suffix.clone(),
2559            cur_marker.clone(),
2560            is_code_block,
2561            code_language.take(),
2562            blockquote_depth,
2563            std::mem::take(&mut pending_style),
2564        );
2565    }
2566
2567    // An empty document still yields a single empty paragraph (matches
2568    // `parse_markdown`).
2569    if elements.is_empty() {
2570        djot_push_block(
2571            &mut elements,
2572            vec![ParsedSpan {
2573                text: String::new(),
2574                ..Default::default()
2575            }],
2576            None,
2577            None,
2578            0,
2579            String::new(),
2580            String::new(),
2581            None,
2582            false,
2583            None,
2584            0,
2585            DjotBlockStyle::default(),
2586        );
2587    }
2588
2589    elements
2590}
2591
2592#[cfg(test)]
2593mod tests {
2594    use super::*;
2595
2596    /// Helper: flatten parse_markdown output to blocks for tests that don't care about tables.
2597    fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
2598        ParsedElement::flatten_to_blocks(parse_markdown(md))
2599    }
2600
2601    #[test]
2602    fn test_parse_markdown_simple_paragraph() {
2603        let blocks = parse_markdown_blocks("Hello **world**");
2604        assert_eq!(blocks.len(), 1);
2605        assert!(blocks[0].spans.len() >= 2);
2606        // "Hello " is plain, "world" is bold
2607        let plain_span = blocks[0]
2608            .spans
2609            .iter()
2610            .find(|s| s.text.contains("Hello"))
2611            .unwrap();
2612        assert!(!plain_span.bold);
2613        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2614        assert!(bold_span.bold);
2615    }
2616
2617    #[test]
2618    fn test_parse_markdown_heading() {
2619        let blocks = parse_markdown_blocks("# Title");
2620        assert_eq!(blocks.len(), 1);
2621        assert_eq!(blocks[0].heading_level, Some(1));
2622        assert_eq!(blocks[0].spans[0].text, "Title");
2623    }
2624
2625    #[test]
2626    fn test_parse_markdown_list() {
2627        let blocks = parse_markdown_blocks("- item1\n- item2");
2628        assert!(blocks.len() >= 2);
2629        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2630        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2631    }
2632
2633    /// Helper: extract (is_table, blockquote_depth) per element for nesting assertions.
2634    fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
2635        elements
2636            .iter()
2637            .map(|e| match e {
2638                ParsedElement::Block(b) => (false, b.blockquote_depth),
2639                ParsedElement::Table(t) => (true, t.blockquote_depth),
2640                // Definitions carry no blockquote nesting of their own; this
2641                // helper exists for the nesting assertions and never sees one.
2642                ParsedElement::FootnoteDefinition { .. } => (false, 0),
2643            })
2644            .collect()
2645    }
2646
2647    #[test]
2648    fn test_parse_markdown_table_in_blockquote_records_depth() {
2649        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2650        assert_eq!(element_depths(&elements), vec![(true, 1)]);
2651    }
2652
2653    #[test]
2654    fn test_parse_markdown_text_then_table_in_blockquote() {
2655        let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
2656        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
2657    }
2658
2659    #[test]
2660    fn test_parse_markdown_table_after_blockquote_closes() {
2661        let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
2662        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
2663    }
2664
2665    #[test]
2666    fn test_parse_markdown_table_in_nested_blockquote() {
2667        let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
2668        assert_eq!(element_depths(&elements), vec![(true, 2)]);
2669    }
2670
2671    #[test]
2672    fn test_parse_markdown_list_in_blockquote_records_depth() {
2673        let elements = parse_markdown("> - item1\n> - item2");
2674        let depths = element_depths(&elements);
2675        assert_eq!(depths, vec![(false, 1), (false, 1)]);
2676        for e in &elements {
2677            if let ParsedElement::Block(b) = e {
2678                assert_eq!(b.list_style, Some(ListStyle::Disc));
2679            }
2680        }
2681    }
2682
2683    #[test]
2684    fn test_parse_html_table_in_blockquote_records_depth() {
2685        let elements = parse_html_elements(
2686            "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
2687        );
2688        assert_eq!(element_depths(&elements), vec![(true, 1)]);
2689    }
2690
2691    #[test]
2692    fn test_parse_html_table_after_blockquote() {
2693        let elements = parse_html_elements(
2694            "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
2695        );
2696        let depths = element_depths(&elements);
2697        // The blockquote paragraph carries depth 1; the table is outside (depth 0).
2698        assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
2699        assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
2700    }
2701
2702    #[test]
2703    fn test_flatten_to_blocks_propagates_blockquote_depth() {
2704        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2705        let blocks = ParsedElement::flatten_to_blocks(elements);
2706        assert!(!blocks.is_empty());
2707        for b in &blocks {
2708            assert_eq!(b.blockquote_depth, 1);
2709        }
2710    }
2711
2712    #[test]
2713    fn test_parse_html_simple() {
2714        let blocks = parse_html("<p>Hello <b>world</b></p>");
2715        assert_eq!(blocks.len(), 1);
2716        assert!(blocks[0].spans.len() >= 2);
2717        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2718        assert!(bold_span.bold);
2719    }
2720
2721    #[test]
2722    fn test_parse_html_multiple_paragraphs() {
2723        let blocks = parse_html("<p>A</p><p>B</p>");
2724        assert_eq!(blocks.len(), 2);
2725    }
2726
2727    #[test]
2728    fn test_parse_html_heading() {
2729        let blocks = parse_html("<h2>Subtitle</h2>");
2730        assert_eq!(blocks.len(), 1);
2731        assert_eq!(blocks[0].heading_level, Some(2));
2732    }
2733
2734    #[test]
2735    fn test_parse_html_list() {
2736        let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2737        assert!(blocks.len() >= 2);
2738        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2739    }
2740
2741    #[test]
2742    fn test_parse_markdown_code_block() {
2743        let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2744        assert_eq!(blocks.len(), 1);
2745        assert!(blocks[0].is_code_block);
2746        assert!(blocks[0].spans[0].code);
2747        // pulldown-cmark appends a trailing \n to code block text — verify it's stripped
2748        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2749        assert_eq!(
2750            text, "fn main() {}",
2751            "code block text should not have trailing newline"
2752        );
2753    }
2754
2755    #[test]
2756    fn test_parse_markdown_nested_formatting() {
2757        let blocks = parse_markdown_blocks("***bold italic***");
2758        assert_eq!(blocks.len(), 1);
2759        let span = &blocks[0].spans[0];
2760        assert!(span.bold);
2761        assert!(span.italic);
2762    }
2763
2764    #[test]
2765    fn test_parse_markdown_link() {
2766        let blocks = parse_markdown_blocks("[click](http://example.com)");
2767        assert_eq!(blocks.len(), 1);
2768        let span = &blocks[0].spans[0];
2769        assert_eq!(span.text, "click");
2770        assert_eq!(span.link_href, Some("http://example.com".to_string()));
2771    }
2772
2773    #[test]
2774    fn test_parse_markdown_empty() {
2775        let blocks = parse_markdown_blocks("");
2776        assert_eq!(blocks.len(), 1);
2777        assert!(blocks[0].spans[0].text.is_empty());
2778    }
2779
2780    #[test]
2781    fn test_parse_html_empty() {
2782        let blocks = parse_html("");
2783        assert_eq!(blocks.len(), 1);
2784        assert!(blocks[0].spans[0].text.is_empty());
2785    }
2786
2787    #[test]
2788    fn test_parse_html_nested_formatting() {
2789        let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
2790        assert_eq!(blocks.len(), 1);
2791        let span = &blocks[0].spans[0];
2792        assert!(span.bold);
2793        assert!(span.italic);
2794    }
2795
2796    #[test]
2797    fn test_parse_html_link() {
2798        let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
2799        assert_eq!(blocks.len(), 1);
2800        let span = &blocks[0].spans[0];
2801        assert_eq!(span.text, "click");
2802        assert_eq!(span.link_href, Some("http://example.com".to_string()));
2803    }
2804
2805    #[test]
2806    fn test_parse_html_ordered_list() {
2807        let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
2808        assert!(blocks.len() >= 2);
2809        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2810    }
2811
2812    #[test]
2813    fn test_parse_markdown_ordered_list() {
2814        let blocks = parse_markdown_blocks("1. first\n2. second");
2815        assert!(blocks.len() >= 2);
2816        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2817    }
2818
2819    #[test]
2820    fn test_parse_html_blockquote_nested() {
2821        let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
2822        assert!(blocks.len() >= 3);
2823    }
2824
2825    #[test]
2826    fn test_parse_block_styles_line_height() {
2827        let styles = parse_block_styles("line-height: 1.5");
2828        assert_eq!(styles.line_height, Some(1500));
2829    }
2830
2831    #[test]
2832    fn test_parse_block_styles_direction_rtl() {
2833        let styles = parse_block_styles("direction: rtl");
2834        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2835    }
2836
2837    #[test]
2838    fn test_parse_block_styles_background_color() {
2839        let styles = parse_block_styles("background-color: #ff0000");
2840        assert_eq!(styles.background_color, Some("#ff0000".to_string()));
2841    }
2842
2843    #[test]
2844    fn test_parse_block_styles_white_space_pre() {
2845        let styles = parse_block_styles("white-space: pre");
2846        assert_eq!(styles.non_breakable_lines, Some(true));
2847    }
2848
2849    #[test]
2850    fn test_parse_block_styles_multiple() {
2851        let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
2852        assert_eq!(styles.line_height, Some(2000));
2853        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2854        assert_eq!(styles.background_color, Some("blue".to_string()));
2855    }
2856
2857    #[test]
2858    fn test_parse_html_block_styles_extracted() {
2859        let blocks = parse_html(
2860            r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
2861        );
2862        assert_eq!(blocks.len(), 1);
2863        assert_eq!(blocks[0].line_height, Some(1500));
2864        assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
2865        assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
2866    }
2867
2868    #[test]
2869    fn test_parse_html_white_space_pre() {
2870        let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
2871        assert_eq!(blocks.len(), 1);
2872        assert_eq!(blocks[0].non_breakable_lines, Some(true));
2873    }
2874
2875    #[test]
2876    fn test_parse_html_no_styles_returns_none() {
2877        let blocks = parse_html("<p>plain</p>");
2878        assert_eq!(blocks.len(), 1);
2879        assert_eq!(blocks[0].line_height, None);
2880        assert_eq!(blocks[0].direction, None);
2881        assert_eq!(blocks[0].background_color, None);
2882        assert_eq!(blocks[0].non_breakable_lines, None);
2883    }
2884
2885    #[test]
2886    fn test_parse_markdown_nested_list_indent() {
2887        let md = "- top\n  - nested\n    - deep";
2888        let blocks = parse_markdown_blocks(md);
2889        assert_eq!(blocks.len(), 3);
2890        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2891        assert_eq!(blocks[0].list_indent, 0);
2892        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2893        assert_eq!(blocks[1].list_indent, 1);
2894        assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
2895        assert_eq!(blocks[2].list_indent, 2);
2896    }
2897
2898    #[test]
2899    fn test_parse_markdown_nested_ordered_list_indent() {
2900        let md = "1. first\n   1. nested\n   2. nested2";
2901        let blocks = parse_markdown_blocks(md);
2902        assert_eq!(blocks.len(), 3);
2903        assert_eq!(blocks[0].list_indent, 0);
2904        assert_eq!(blocks[1].list_indent, 1);
2905        assert_eq!(blocks[2].list_indent, 1);
2906    }
2907
2908    #[test]
2909    fn test_parse_html_nested_list_indent() {
2910        let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
2911        let blocks = parse_html(html);
2912        assert!(blocks.len() >= 2);
2913        assert_eq!(blocks[0].list_indent, 0);
2914        assert_eq!(blocks[1].list_indent, 1);
2915    }
2916
2917    #[test]
2918    fn test_parse_markdown_table() {
2919        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
2920        let elements = parse_markdown(md);
2921        assert_eq!(elements.len(), 1);
2922        match &elements[0] {
2923            ParsedElement::Table(table) => {
2924                assert_eq!(table.header_rows, 1);
2925                assert_eq!(table.rows.len(), 2); // 1 header + 1 body
2926                // Header row
2927                assert_eq!(table.rows[0].len(), 2);
2928                assert_eq!(table.rows[0][0].spans[0].text, "A");
2929                assert_eq!(table.rows[0][1].spans[0].text, "B");
2930                // Body row
2931                assert_eq!(table.rows[1].len(), 2);
2932                assert_eq!(table.rows[1][0].spans[0].text, "1");
2933                assert_eq!(table.rows[1][1].spans[0].text, "2");
2934            }
2935            _ => panic!("Expected ParsedElement::Table"),
2936        }
2937    }
2938
2939    #[test]
2940    fn test_parse_markdown_table_with_formatting() {
2941        let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
2942        let elements = parse_markdown(md);
2943        assert_eq!(elements.len(), 1);
2944        match &elements[0] {
2945            ParsedElement::Table(table) => {
2946                assert_eq!(table.rows.len(), 2);
2947                // Header: bold cell
2948                assert!(table.rows[0][0].spans[0].bold);
2949                // Header: code cell
2950                assert!(table.rows[0][1].spans[0].code);
2951                // Header: italic cell
2952                assert!(table.rows[0][2].spans[0].italic);
2953                // Body: strikeout cell
2954                assert!(table.rows[1][0].spans[0].strikeout);
2955                // Body: link cell
2956                assert_eq!(
2957                    table.rows[1][2].spans[0].link_href,
2958                    Some("http://x.com".to_string())
2959                );
2960            }
2961            _ => panic!("Expected ParsedElement::Table"),
2962        }
2963    }
2964
2965    #[test]
2966    fn test_parse_markdown_mixed_content_with_table() {
2967        let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
2968        let elements = parse_markdown(md);
2969        assert_eq!(elements.len(), 3);
2970        assert!(matches!(&elements[0], ParsedElement::Block(_)));
2971        assert!(matches!(&elements[1], ParsedElement::Table(_)));
2972        assert!(matches!(&elements[2], ParsedElement::Block(_)));
2973    }
2974}
2975
2976#[cfg(test)]
2977mod djot_tests {
2978    use super::*;
2979    use crate::entities::MarkerType;
2980
2981    fn blocks(d: &str) -> Vec<ParsedBlock> {
2982        ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
2983    }
2984
2985    fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
2986        b.spans.iter().find(|s| pred(s)).expect("span not found")
2987    }
2988
2989    #[test]
2990    fn paragraph_bold_italic() {
2991        let b = blocks("normal *bold* _italic_");
2992        assert_eq!(b.len(), 1);
2993        assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
2994        assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
2995    }
2996
2997    #[test]
2998    fn heading_levels() {
2999        assert_eq!(blocks("# H1")[0].heading_level, Some(1));
3000        assert_eq!(blocks("### H3")[0].heading_level, Some(3));
3001        assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
3002    }
3003
3004    #[test]
3005    fn unordered_bullet_styles_are_distinct() {
3006        assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
3007        assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
3008        assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
3009    }
3010
3011    #[test]
3012    fn ordered_delimiters() {
3013        let period = blocks("1. a");
3014        assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
3015        assert_eq!(period[0].list_prefix, "");
3016        assert_eq!(period[0].list_suffix, ".");
3017
3018        let paren = blocks("1) a");
3019        assert_eq!(paren[0].list_suffix, ")");
3020        assert_eq!(paren[0].list_prefix, "");
3021
3022        let paren_paren = blocks("(1) a");
3023        assert_eq!(paren_paren[0].list_prefix, "(");
3024        assert_eq!(paren_paren[0].list_suffix, ")");
3025    }
3026
3027    #[test]
3028    fn task_list_markers() {
3029        let b = blocks("- [ ] a\n- [x] b");
3030        assert_eq!(b.len(), 2);
3031        assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
3032        assert_eq!(b[1].marker, Some(MarkerType::Checked));
3033    }
3034
3035    #[test]
3036    fn code_block_with_language() {
3037        let b = blocks("```rust\nfn main() {}\n```");
3038        assert_eq!(b.len(), 1);
3039        assert!(b[0].is_code_block);
3040        assert_eq!(b[0].code_language.as_deref(), Some("rust"));
3041        let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
3042        assert_eq!(text, "fn main() {}");
3043    }
3044
3045    #[test]
3046    fn link_href() {
3047        let b = blocks("[text](http://example.com)");
3048        let s = first_span_with(&b[0], |s| s.text == "text");
3049        assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
3050    }
3051
3052    #[test]
3053    fn superscript_subscript() {
3054        assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
3055        assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
3056    }
3057
3058    #[test]
3059    fn delete_insert_verbatim() {
3060        assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
3061        assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
3062        assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
3063    }
3064
3065    #[test]
3066    fn blockquote_depth() {
3067        let els = parse_djot("> quoted", &DjotImportOptions::default());
3068        match &els[0] {
3069            ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
3070            _ => panic!("expected block"),
3071        }
3072    }
3073
3074    #[test]
3075    fn nested_list_indent() {
3076        // Djot nests a sub-list only when a blank line separates it from the
3077        // parent item and it is indented to the parent's content column
3078        // (2 spaces per level). Without the blank line the markers fold into
3079        // the paragraph as lazy continuation.
3080        let b = blocks("- a\n\n  - b\n\n    - c");
3081        assert_eq!(b.len(), 3);
3082        assert_eq!(b[0].list_indent, 0);
3083        assert_eq!(b[1].list_indent, 1);
3084        assert_eq!(b[2].list_indent, 2);
3085    }
3086
3087    #[test]
3088    fn table_parsed_as_table() {
3089        let els = parse_djot(
3090            "| a | b |\n|---|---|\n| c | d |",
3091            &DjotImportOptions::default(),
3092        );
3093        assert_eq!(els.len(), 1);
3094        match &els[0] {
3095            ParsedElement::Table(t) => {
3096                assert_eq!(t.header_rows, 1);
3097                assert_eq!(t.rows.len(), 2);
3098                assert_eq!(t.rows[0][0].spans[0].text, "a");
3099                assert_eq!(t.rows[1][1].spans[0].text, "d");
3100            }
3101            _ => panic!("expected table"),
3102        }
3103    }
3104
3105    #[test]
3106    fn smart_punctuation_normalised_to_unicode() {
3107        let text: String = blocks("a... b---c")[0]
3108            .spans
3109            .iter()
3110            .map(|s| s.text.as_str())
3111            .collect();
3112        assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
3113        assert!(text.contains('\u{2014}'), "em dash: {text:?}");
3114    }
3115
3116    #[test]
3117    fn unrepresentable_constructs_dropped_without_leaking_text() {
3118        // Thematic break between two paragraphs: no extra block, no stray text.
3119        let b = blocks("para1\n\n---\n\npara2");
3120        assert_eq!(b.len(), 2);
3121        assert_eq!(
3122            b[0].spans
3123                .iter()
3124                .map(|s| s.text.as_str())
3125                .collect::<String>(),
3126            "para1"
3127        );
3128        assert_eq!(
3129            b[1].spans
3130                .iter()
3131                .map(|s| s.text.as_str())
3132                .collect::<String>(),
3133            "para2"
3134        );
3135
3136        // Fenced div is unwrapped: its content survives, the fence does not.
3137        let d = blocks(":::\ninside\n:::");
3138        let joined: String = d
3139            .iter()
3140            .flat_map(|b| b.spans.iter())
3141            .map(|s| s.text.as_str())
3142            .collect();
3143        assert_eq!(joined, "inside");
3144
3145        // Inline math content is dropped, surrounding text kept.
3146        let m = blocks("before $`E=mc^2` after");
3147        let joined: String = m
3148            .iter()
3149            .flat_map(|b| b.spans.iter())
3150            .map(|s| s.text.as_str())
3151            .collect();
3152        assert!(joined.contains("before"), "{joined:?}");
3153        assert!(joined.contains("after"), "{joined:?}");
3154        assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
3155    }
3156
3157    #[test]
3158    fn empty_document_yields_one_empty_block() {
3159        let b = blocks("");
3160        assert_eq!(b.len(), 1);
3161        assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
3162    }
3163
3164    #[test]
3165    fn block_attributes_parse_into_block() {
3166        let b = blocks(
3167            "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
3168        );
3169        assert_eq!(b.len(), 1);
3170        assert_eq!(b[0].alignment, Some(Alignment::Center));
3171        assert_eq!(b[0].line_height, Some(1500));
3172        assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
3173        assert_eq!(b[0].non_breakable_lines, Some(true));
3174        assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
3175    }
3176
3177    #[test]
3178    fn spacing_block_attributes_parse_into_block() {
3179        // `top_margin` / `text_indent` let one block override the document-wide
3180        // paragraph spacing and first-line indent — what a scene break needs for
3181        // the paragraph that follows it.
3182        let b = blocks("{top_margin=24 text_indent=0}\nhello");
3183        assert_eq!(b.len(), 1);
3184        assert_eq!(b[0].top_margin, Some(24));
3185        assert_eq!(b[0].text_indent, Some(0));
3186    }
3187
3188    #[test]
3189    fn a_zero_text_indent_is_distinct_from_an_absent_one() {
3190        // The whole point of the attribute: `Some(0)` means "explicitly no
3191        // indent", which must not collapse to `None` ("use the document
3192        // default") — otherwise a scene break could not suppress the indent.
3193        let explicit = blocks("{text_indent=0}\nhello");
3194        let absent = blocks("hello");
3195        assert_eq!(explicit[0].text_indent, Some(0));
3196        assert_eq!(absent[0].text_indent, None);
3197        assert!(!explicit[0].is_inline_only());
3198        assert!(absent[0].is_inline_only());
3199    }
3200
3201    #[test]
3202    fn spacing_block_attributes_respect_import_options() {
3203        let src = "{top_margin=24 text_indent=0}\nhello";
3204        let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3205        assert_eq!(b[0].top_margin, None);
3206        assert_eq!(b[0].text_indent, None);
3207    }
3208
3209    #[test]
3210    fn block_attributes_on_heading() {
3211        let b = blocks("{alignment=right}\n# Title");
3212        assert_eq!(b[0].heading_level, Some(1));
3213        assert_eq!(b[0].alignment, Some(Alignment::Right));
3214    }
3215
3216    #[test]
3217    fn block_attributes_respect_import_options() {
3218        // With every optional attribute disabled, the `{…}` block attributes are
3219        // parsed and discarded — only the core paragraph survives.
3220        let src = "{alignment=center line_height=1500}\nhello";
3221        let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3222        assert_eq!(b[0].alignment, None);
3223        assert_eq!(b[0].line_height, None);
3224        assert_eq!(
3225            b[0].spans
3226                .iter()
3227                .map(|s| s.text.as_str())
3228                .collect::<String>(),
3229            "hello"
3230        );
3231    }
3232
3233    #[test]
3234    fn list_item_block_attributes_are_dropped() {
3235        // Block attributes only bind to standalone paragraphs/headings; a list
3236        // item normalises them away (symmetric with the exporter).
3237        let b = blocks("{alignment=center}\n- item");
3238        assert!(b.iter().all(|blk| blk.alignment.is_none()));
3239    }
3240
3241    #[test]
3242    fn unknown_alignment_value_is_ignored() {
3243        let b = blocks("{alignment=sideways}\nhello");
3244        assert_eq!(b[0].alignment, None);
3245    }
3246}
3247
3248// ─── Cheap plain-text extraction ─────────────────────────────────────
3249
3250/// The `U+FFFC OBJECT REPLACEMENT CHARACTER` that stands for a table in the document's
3251/// text.
3252///
3253/// A table is not prose, but it *occupies a position* in the flow: the import mirrors this
3254/// single sentinel into the rope where the table sits
3255/// (`rope_helpers::rope_append_table_anchor`), then the cells as ordinary blocks. Anything
3256/// reconstructing the text a search runs against has to reproduce it, or every offset after
3257/// the first table is short by the two characters (the sentinel and its separator) that the
3258/// document really holds there.
3259pub const TABLE_ANCHOR: &str = "\u{FFFC}";
3260
3261/// The prose of a Djot document, with no entities, no store, and no threads.
3262///
3263/// [`parse_djot`] and [`ParsedElement::flatten_to_blocks`] were both already `pub`;
3264/// nothing chained them. This does, and that is the whole trick: it stops at the
3265/// *parse*, where a full import goes on to create a `Block` entity per paragraph, list
3266/// item and table cell, mirror each into the rope, and write its format runs.
3267///
3268/// # Why a project-wide search needs this
3269///
3270/// A host app searching a manuscript must ask "does this scene contain that word" of
3271/// **thousands** of Djot rows, on every keystroke. Doing that by importing each one into
3272/// a document is not a slow feature, it is a frozen app.
3273///
3274/// And searching the Djot *source* instead — the tempting shortcut — is simply wrong:
3275/// the source is markup. `http` matches inside a link's URL, `*` matches an emphasis
3276/// marker, and an occurrence count taken from the source does not agree with what a
3277/// replace re-derives inside the parsed document. Where a replace guards itself with
3278/// "the text moved under me, skip this field", a count taken from markup makes that
3279/// guard fire on perfectly good rows.
3280///
3281/// # The contract
3282///
3283/// The result is **byte-identical to the text the document searches** for the same Djot:
3284/// each block's spans concatenated, blocks joined by a single `\n`, and a table announced
3285/// by its [`TABLE_ANCHOR`] sentinel — exactly the string the import mirrors into the rope
3286/// and that `build_full_text_via_store` recomposes. So an offset found here is an offset
3287/// the document agrees with.
3288///
3289/// A property in `djot_roundtrip_tests` pins that across the whole generated feature set;
3290/// without it this would be a second, silently-diverging definition of "the text".
3291///
3292/// ⚠ It is **not** the same as `TextDocument::to_plain_text()`, which walks frames and
3293/// therefore orders a blockquote's prose differently (`"> a0\n\na"` exports as `"a\na0"`
3294/// but is *searched* as `"a0\na"`). The authority is what a search sees, because that is
3295/// what a replace edits. See `claude_reviews/text-document-plain-text-ordering.md`.
3296/// One span's contribution to the addressable text.
3297///
3298/// An inline object carries no prose but **does** occupy one `U+FFFC` in the
3299/// document (`format_runs_from_spans` mirrors it there), so it has to occupy one
3300/// here too. Leaving it out makes this string shorter than the document it
3301/// claims to be byte-identical to, and every offset past the object — every
3302/// search hit, every comment anchor — lands a character early.
3303fn span_prose(span: &ParsedSpan, out: &mut String) {
3304    if span.image.is_some() || span.footnote_ref.is_some() {
3305        out.push('\u{FFFC}');
3306        return;
3307    }
3308    out.push_str(&span.text);
3309}
3310
3311fn block_prose(block: &ParsedBlock) -> String {
3312    let mut prose = String::new();
3313    for span in &block.spans {
3314        span_prose(span, &mut prose);
3315    }
3316    prose
3317}
3318
3319fn cell_prose(cell: &ParsedTableCell) -> String {
3320    let mut prose = String::new();
3321    for span in &cell.spans {
3322        span_prose(span, &mut prose);
3323    }
3324    prose
3325}
3326
3327pub fn djot_to_plain_text(djot: &str, options: &DjotImportOptions) -> String {
3328    // Deliberately NOT `ParsedElement::flatten_to_blocks`: that helper drops a table's
3329    // anchor and yields only its cells, which would silently shift every offset in a
3330    // document containing a table by the two characters the document actually holds there.
3331    let elements = parse_djot(djot, options);
3332
3333    // Sized from the source: the prose is always shorter than the markup that carries it,
3334    // so this allocates once and never grows.
3335    let mut out = String::with_capacity(djot.len());
3336
3337    // The separator is decided by "is this the first block", NOT by "is the output still
3338    // empty". An EMPTY block (an empty code fence, say) is still a block: the document
3339    // holds an empty line for it, and an emptiness test would swallow both the block and
3340    // its separator, shifting every offset after it by one.
3341    let mut first = true;
3342    let push = |text: &str, out: &mut String, first: &mut bool| {
3343        if *first {
3344            *first = false;
3345        } else {
3346            out.push('\n');
3347        }
3348        out.push_str(text);
3349    };
3350
3351    for element in &elements {
3352        match element {
3353            ParsedElement::Block(block) => {
3354                push(&block_prose(block), &mut out, &mut first);
3355            }
3356            // A note's body is **out of flow**: it is not laid out where its
3357            // definition was written, it is not part of a copied fragment, and
3358            // the document does not count its characters. So it is not part of
3359            // the addressable text either — which is the one thing that has to
3360            // stay true here, since `character_count()` and this string are
3361            // compared directly.
3362            //
3363            // Its blocks do live in the rope, because they have to live
3364            // somewhere; they are simply not addressable prose.
3365            ParsedElement::FootnoteDefinition { .. } => {}
3366            ParsedElement::Table(table) => {
3367                // The import mirrors a table into the rope as a lone `U+FFFC` sentinel
3368                // followed by its cells, one per block (`rope_append_table_anchor`). The
3369                // sentinel occupies a real position in the text the document searches, so
3370                // it has to occupy one here too — otherwise every offset after a table is
3371                // short by two characters, and a snippet taken from this string would be
3372                // sliced in the wrong place.
3373                push(TABLE_ANCHOR, &mut out, &mut first);
3374                for row in &table.rows {
3375                    for cell in row {
3376                        push(&cell_prose(cell), &mut out, &mut first);
3377                    }
3378                }
3379            }
3380        }
3381    }
3382    out
3383}