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        superscript: bool,
1043        subscript: bool,
1044        link_href: Option<String>,
1045    }
1046
1047    const MAX_RECURSION_DEPTH: usize = 256;
1048
1049    /// Elements whose text content is machinery, not prose.
1050    ///
1051    /// `<style>` and `<script>` hold raw text the HTML spec never renders, and
1052    /// `<head>` and its metadata children carry none a reader is meant to see.
1053    /// Every walker below recurses into unknown tags on the assumption that
1054    /// they are inline wrappers, which turns a stylesheet into paragraphs —
1055    /// and Word, Google Docs and Chrome all put a `<style>` block in the
1056    /// `text/html` flavour they publish to the clipboard, so this is the
1057    /// ordinary paste, not an exotic one.
1058    fn is_metadata_tag(tag: &str) -> bool {
1059        matches!(
1060            tag,
1061            "head"
1062                | "style"
1063                | "script"
1064                | "title"
1065                | "meta"
1066                | "link"
1067                | "base"
1068                | "noscript"
1069                | "template"
1070        )
1071    }
1072
1073    /// Collect inline spans from a `<td>` or `<th>` cell element.
1074    fn collect_cell_spans(
1075        node: ego_tree::NodeRef<Node>,
1076        state: &FmtState,
1077        spans: &mut Vec<ParsedSpan>,
1078        depth: usize,
1079    ) {
1080        if depth > MAX_RECURSION_DEPTH {
1081            return;
1082        }
1083        for child in node.children() {
1084            match child.value() {
1085                Node::Text(text) => {
1086                    let t = text.text.to_string();
1087                    if !t.is_empty() {
1088                        spans.push(ParsedSpan {
1089                            text: t,
1090                            bold: state.bold,
1091                            italic: state.italic,
1092                            underline: state.underline,
1093                            strikeout: state.strikeout,
1094                            code: state.code,
1095                            superscript: state.superscript,
1096                            subscript: state.subscript,
1097                            link_href: state.link_href.clone(),
1098                            image: None,
1099                            footnote_ref: None,
1100                        });
1101                    }
1102                }
1103                Node::Element(el) => {
1104                    let tag = el.name();
1105                    if is_metadata_tag(tag) {
1106                        continue;
1107                    }
1108                    let mut new_state = state.clone();
1109                    match tag {
1110                        "b" | "strong" => new_state.bold = true,
1111                        "i" | "em" => new_state.italic = true,
1112                        "u" | "ins" => new_state.underline = true,
1113                        "s" | "del" | "strike" => new_state.strikeout = true,
1114                        "code" => new_state.code = true,
1115                        "sup" => new_state.superscript = true,
1116                        "sub" => new_state.subscript = true,
1117                        "a" => {
1118                            if let Some(href) = el.attr("href") {
1119                                new_state.link_href = Some(href.to_string());
1120                            }
1121                        }
1122                        "img" => {
1123                            if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1124                                spans.push(span);
1125                            }
1126                            continue;
1127                        }
1128                        _ => {}
1129                    }
1130                    collect_cell_spans(child, &new_state, spans, depth + 1);
1131                }
1132                _ => {}
1133            }
1134        }
1135    }
1136
1137    /// Parse a `<table>` element into a ParsedTable.
1138    fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
1139        let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1140        let mut header_rows: usize = 0;
1141
1142        fn collect_rows(
1143            node: ego_tree::NodeRef<Node>,
1144            rows: &mut Vec<Vec<ParsedTableCell>>,
1145            header_rows: &mut usize,
1146            in_thead: bool,
1147        ) {
1148            for child in node.children() {
1149                if let Node::Element(el) = child.value() {
1150                    match el.name() {
1151                        "thead" => collect_rows(child, rows, header_rows, true),
1152                        "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
1153                        "tr" => {
1154                            let mut cells: Vec<ParsedTableCell> = Vec::new();
1155                            for td in child.children() {
1156                                if let Node::Element(td_el) = td.value()
1157                                    && matches!(td_el.name(), "td" | "th")
1158                                {
1159                                    let mut spans = Vec::new();
1160                                    let state = FmtState::default();
1161                                    collect_cell_spans(td, &state, &mut spans, 0);
1162                                    if spans.is_empty() {
1163                                        spans.push(ParsedSpan::default());
1164                                    }
1165                                    cells.push(ParsedTableCell { spans });
1166                                }
1167                            }
1168                            if !cells.is_empty() {
1169                                rows.push(cells);
1170                                if in_thead {
1171                                    *header_rows += 1;
1172                                }
1173                            }
1174                        }
1175                        _ => {}
1176                    }
1177                }
1178            }
1179        }
1180
1181        collect_rows(table_node, &mut rows, &mut header_rows, false);
1182
1183        // Tables without explicit <thead> but with <th> cells: treat first row as header
1184        if header_rows == 0 && !rows.is_empty() {
1185            header_rows = 1;
1186        }
1187
1188        ParsedTable {
1189            header_rows,
1190            rows,
1191            // The caller (`walk_node`) sets the real depth — this helper has
1192            // no visibility into the surrounding blockquote nesting.
1193            blockquote_depth: 0,
1194        }
1195    }
1196
1197    fn walk_node(
1198        node: ego_tree::NodeRef<Node>,
1199        state: &FmtState,
1200        elements: &mut Vec<ParsedElement>,
1201        current_list_style: &Option<ListStyle>,
1202        blockquote_depth: u32,
1203        list_depth: u32,
1204        depth: usize,
1205    ) {
1206        if depth > MAX_RECURSION_DEPTH {
1207            return;
1208        }
1209        match node.value() {
1210            Node::Element(el) => {
1211                let tag = el.name();
1212                if is_metadata_tag(tag) {
1213                    return;
1214                }
1215                let mut new_state = state.clone();
1216                let mut new_list_style = current_list_style.clone();
1217                let mut bq_depth = blockquote_depth;
1218                let mut new_list_depth = list_depth;
1219
1220                // Determine if this is a block-level element
1221                let is_block_tag = matches!(
1222                    tag,
1223                    "p" | "div"
1224                        | "h1"
1225                        | "h2"
1226                        | "h3"
1227                        | "h4"
1228                        | "h5"
1229                        | "h6"
1230                        | "li"
1231                        | "pre"
1232                        | "br"
1233                        | "blockquote"
1234                        | "body"
1235                        | "html"
1236                );
1237
1238                // Update formatting state
1239                match tag {
1240                    "b" | "strong" => new_state.bold = true,
1241                    "i" | "em" => new_state.italic = true,
1242                    "u" | "ins" => new_state.underline = true,
1243                    "s" | "del" | "strike" => new_state.strikeout = true,
1244                    "code" => new_state.code = true,
1245                    "sup" => new_state.superscript = true,
1246                    "sub" => new_state.subscript = true,
1247                    "a" => {
1248                        if let Some(href) = el.attr("href") {
1249                            new_state.link_href = Some(href.to_string());
1250                        }
1251                    }
1252                    "ul" => {
1253                        new_list_style = Some(ListStyle::Disc);
1254                        new_list_depth = list_depth + 1;
1255                    }
1256                    "ol" => {
1257                        new_list_style = Some(ListStyle::Decimal);
1258                        new_list_depth = list_depth + 1;
1259                    }
1260                    "blockquote" => {
1261                        bq_depth += 1;
1262                    }
1263                    _ => {}
1264                }
1265
1266                // Determine heading level
1267                let heading_level = match tag {
1268                    "h1" => Some(1),
1269                    "h2" => Some(2),
1270                    "h3" => Some(3),
1271                    "h4" => Some(4),
1272                    "h5" => Some(5),
1273                    "h6" => Some(6),
1274                    _ => None,
1275                };
1276
1277                let is_code_block = tag == "pre";
1278
1279                // Extract code language from <pre><code class="language-xxx">
1280                let code_language = if is_code_block {
1281                    node.children().find_map(|child| {
1282                        if let Node::Element(cel) = child.value()
1283                            && cel.name() == "code"
1284                            && let Some(cls) = cel.attr("class")
1285                        {
1286                            return cls
1287                                .split_whitespace()
1288                                .find_map(|c| c.strip_prefix("language-"))
1289                                .map(|l| l.to_string());
1290                        }
1291                        None
1292                    })
1293                } else {
1294                    None
1295                };
1296
1297                // Extract CSS styles from block-level elements
1298                let css = if is_block_tag {
1299                    el.attr("style").map(parse_block_styles).unwrap_or_default()
1300                } else {
1301                    BlockStyles::default()
1302                };
1303
1304                if tag == "table" {
1305                    // Parse table structure into a ParsedTable
1306                    let mut parsed_table = parse_table_element(node);
1307                    if !parsed_table.rows.is_empty() {
1308                        parsed_table.blockquote_depth = bq_depth;
1309                        elements.push(ParsedElement::Table(parsed_table));
1310                    }
1311                    return;
1312                }
1313
1314                if tag == "br" {
1315                    // <br> creates a new block
1316                    elements.push(ParsedElement::Block(ParsedBlock {
1317                        spans: vec![ParsedSpan {
1318                            text: String::new(),
1319                            ..Default::default()
1320                        }],
1321                        heading_level: None,
1322                        list_style: None,
1323                        list_indent: 0,
1324                        list_prefix: String::new(),
1325                        list_suffix: String::new(),
1326                        marker: None,
1327                        is_code_block: false,
1328                        code_language: None,
1329                        blockquote_depth: bq_depth,
1330                        line_height: None,
1331                        non_breakable_lines: None,
1332                        page_break_before: None,
1333                        direction: None,
1334                        background_color: None,
1335                        alignment: None,
1336                        top_margin: None,
1337                        text_indent: None,
1338                        semantic_role: None,
1339                    }));
1340                    return;
1341                }
1342
1343                if tag == "blockquote" {
1344                    // Blockquote is a container — recurse into children with increased depth
1345                    for child in node.children() {
1346                        walk_node(
1347                            child,
1348                            &new_state,
1349                            elements,
1350                            &new_list_style,
1351                            bq_depth,
1352                            new_list_depth,
1353                            depth + 1,
1354                        );
1355                    }
1356                } else if is_block_tag && tag != "br" {
1357                    // Start collecting spans for a new block.
1358                    // Use a temporary buffer so that nested block-level
1359                    // elements (e.g. sub-lists inside <li>) are collected
1360                    // separately and appended *after* the parent block.
1361                    let mut spans: Vec<ParsedSpan> = Vec::new();
1362                    let mut nested_elements: Vec<ParsedElement> = Vec::new();
1363                    collect_inline_spans(
1364                        node,
1365                        &new_state,
1366                        &mut spans,
1367                        &new_list_style,
1368                        &mut nested_elements,
1369                        bq_depth,
1370                        new_list_depth,
1371                        depth + 1,
1372                    );
1373
1374                    let list_style_for_block = if tag == "li" {
1375                        new_list_style.clone()
1376                    } else {
1377                        None
1378                    };
1379
1380                    let list_indent_for_block = if tag == "li" {
1381                        new_list_depth.saturating_sub(1)
1382                    } else {
1383                        0
1384                    };
1385
1386                    if !spans.is_empty() || heading_level.is_some() {
1387                        elements.push(ParsedElement::Block(ParsedBlock {
1388                            spans,
1389                            heading_level,
1390                            list_style: list_style_for_block,
1391                            list_indent: list_indent_for_block,
1392                            list_prefix: String::new(),
1393                            list_suffix: String::new(),
1394                            marker: None,
1395                            is_code_block,
1396                            code_language,
1397                            blockquote_depth: bq_depth,
1398                            line_height: css.line_height,
1399                            non_breakable_lines: css.non_breakable_lines,
1400                            page_break_before: css.page_break_before,
1401                            direction: css.direction,
1402                            background_color: css.background_color,
1403                            alignment: None,
1404                            top_margin: None,
1405                            text_indent: None,
1406                            semantic_role: None,
1407                        }));
1408                    }
1409                    // Append nested block elements after the parent block
1410                    elements.append(&mut nested_elements);
1411                } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
1412                    // Container elements: recurse into children
1413                    for child in node.children() {
1414                        walk_node(
1415                            child,
1416                            &new_state,
1417                            elements,
1418                            &new_list_style,
1419                            bq_depth,
1420                            new_list_depth,
1421                            depth + 1,
1422                        );
1423                    }
1424                } else {
1425                    // Inline element or unknown: recurse
1426                    for child in node.children() {
1427                        walk_node(
1428                            child,
1429                            &new_state,
1430                            elements,
1431                            current_list_style,
1432                            bq_depth,
1433                            list_depth,
1434                            depth + 1,
1435                        );
1436                    }
1437                }
1438            }
1439            Node::Text(text) => {
1440                let t = text.text.to_string();
1441                let trimmed = t.trim();
1442                if !trimmed.is_empty() {
1443                    // Bare text not in a block — create a paragraph
1444                    elements.push(ParsedElement::Block(ParsedBlock {
1445                        spans: vec![ParsedSpan {
1446                            text: trimmed.to_string(),
1447                            bold: state.bold,
1448                            italic: state.italic,
1449                            underline: state.underline,
1450                            strikeout: state.strikeout,
1451                            code: state.code,
1452                            superscript: state.superscript,
1453                            subscript: state.subscript,
1454                            link_href: state.link_href.clone(),
1455                            image: None,
1456                            footnote_ref: None,
1457                        }],
1458                        heading_level: None,
1459                        list_style: None,
1460                        list_indent: 0,
1461                        list_prefix: String::new(),
1462                        list_suffix: String::new(),
1463                        marker: None,
1464                        is_code_block: false,
1465                        code_language: None,
1466                        blockquote_depth,
1467                        line_height: None,
1468                        non_breakable_lines: None,
1469                        page_break_before: None,
1470                        direction: None,
1471                        background_color: None,
1472                        alignment: None,
1473                        top_margin: None,
1474                        text_indent: None,
1475                        semantic_role: None,
1476                    }));
1477                }
1478            }
1479            _ => {
1480                // Document, Comment, etc. — recurse children
1481                for child in node.children() {
1482                    walk_node(
1483                        child,
1484                        state,
1485                        elements,
1486                        current_list_style,
1487                        blockquote_depth,
1488                        list_depth,
1489                        depth + 1,
1490                    );
1491                }
1492            }
1493        }
1494    }
1495
1496    /// Collect inline spans from a block-level element's children.
1497    /// If a nested block-level element is encountered, it is flushed as a
1498    /// separate block.
1499    #[allow(clippy::too_many_arguments)]
1500    fn collect_inline_spans(
1501        node: ego_tree::NodeRef<Node>,
1502        state: &FmtState,
1503        spans: &mut Vec<ParsedSpan>,
1504        current_list_style: &Option<ListStyle>,
1505        elements: &mut Vec<ParsedElement>,
1506        blockquote_depth: u32,
1507        list_depth: u32,
1508        depth: usize,
1509    ) {
1510        if depth > MAX_RECURSION_DEPTH {
1511            return;
1512        }
1513        for child in node.children() {
1514            match child.value() {
1515                Node::Text(text) => {
1516                    let t = text.text.to_string();
1517                    if !t.is_empty() {
1518                        spans.push(ParsedSpan {
1519                            text: t,
1520                            bold: state.bold,
1521                            italic: state.italic,
1522                            underline: state.underline,
1523                            strikeout: state.strikeout,
1524                            code: state.code,
1525                            superscript: state.superscript,
1526                            subscript: state.subscript,
1527                            link_href: state.link_href.clone(),
1528                            image: None,
1529                            footnote_ref: None,
1530                        });
1531                    }
1532                }
1533                Node::Element(el) => {
1534                    let tag = el.name();
1535                    if is_metadata_tag(tag) {
1536                        continue;
1537                    }
1538                    let mut new_state = state.clone();
1539
1540                    match tag {
1541                        "b" | "strong" => new_state.bold = true,
1542                        "i" | "em" => new_state.italic = true,
1543                        "u" | "ins" => new_state.underline = true,
1544                        "s" | "del" | "strike" => new_state.strikeout = true,
1545                        "code" => new_state.code = true,
1546                        "sup" => new_state.superscript = true,
1547                        "sub" => new_state.subscript = true,
1548                        "a" => {
1549                            if let Some(href) = el.attr("href") {
1550                                new_state.link_href = Some(href.to_string());
1551                            }
1552                        }
1553                        "img" => {
1554                            if let Some(span) = html_img_span(el, new_state.link_href.clone()) {
1555                                spans.push(span);
1556                            }
1557                            continue;
1558                        }
1559                        _ => {}
1560                    }
1561
1562                    // Check for nested block elements
1563                    let nested_block = matches!(
1564                        tag,
1565                        "p" | "div"
1566                            | "h1"
1567                            | "h2"
1568                            | "h3"
1569                            | "h4"
1570                            | "h5"
1571                            | "h6"
1572                            | "li"
1573                            | "pre"
1574                            | "blockquote"
1575                            | "ul"
1576                            | "ol"
1577                    );
1578
1579                    if tag == "br" {
1580                        // br within a block: treat as splitting into new block
1581                        // For simplicity, just add a newline to current span
1582                        spans.push(ParsedSpan {
1583                            text: String::new(),
1584                            ..Default::default()
1585                        });
1586                    } else if nested_block || tag == "table" {
1587                        // Flush as separate element
1588                        walk_node(
1589                            child,
1590                            &new_state,
1591                            elements,
1592                            current_list_style,
1593                            blockquote_depth,
1594                            list_depth,
1595                            depth + 1,
1596                        );
1597                    } else {
1598                        // Inline element: recurse
1599                        collect_inline_spans(
1600                            child,
1601                            &new_state,
1602                            spans,
1603                            current_list_style,
1604                            elements,
1605                            blockquote_depth,
1606                            list_depth,
1607                            depth + 1,
1608                        );
1609                    }
1610                }
1611                _ => {}
1612            }
1613        }
1614    }
1615
1616    let initial_state = FmtState::default();
1617    // Treat the root element as a block-level container so that
1618    // top-level inline elements (e.g. `<b>Bold</b> <em>Italic</em>`)
1619    // are grouped into a single block instead of becoming separate blocks.
1620    let mut root_spans: Vec<ParsedSpan> = Vec::new();
1621    collect_inline_spans(
1622        *root,
1623        &initial_state,
1624        &mut root_spans,
1625        &None,
1626        &mut elements,
1627        0,
1628        0,
1629        0,
1630    );
1631    if !root_spans.is_empty() {
1632        elements.push(ParsedElement::Block(ParsedBlock {
1633            spans: root_spans,
1634            heading_level: None,
1635            list_style: None,
1636            list_indent: 0,
1637            list_prefix: String::new(),
1638            list_suffix: String::new(),
1639            marker: None,
1640            is_code_block: false,
1641            code_language: None,
1642            blockquote_depth: 0,
1643            line_height: None,
1644            non_breakable_lines: None,
1645            page_break_before: None,
1646            direction: None,
1647            background_color: None,
1648            alignment: None,
1649            top_margin: None,
1650            text_indent: None,
1651            semantic_role: None,
1652        }));
1653    }
1654
1655    // If no elements were parsed, create a single empty paragraph
1656    if elements.is_empty() {
1657        elements.push(ParsedElement::Block(ParsedBlock {
1658            spans: vec![ParsedSpan {
1659                text: String::new(),
1660                ..Default::default()
1661            }],
1662            heading_level: None,
1663            list_style: None,
1664            list_indent: 0,
1665            list_prefix: String::new(),
1666            list_suffix: String::new(),
1667            marker: None,
1668            is_code_block: false,
1669            code_language: None,
1670            blockquote_depth: 0,
1671            line_height: None,
1672            non_breakable_lines: None,
1673            page_break_before: None,
1674            direction: None,
1675            background_color: None,
1676            alignment: None,
1677            top_margin: None,
1678            text_indent: None,
1679            semantic_role: None,
1680        }));
1681    }
1682
1683    elements
1684}
1685
1686/// Convert a `ParsedSpan` (parser output) into the `CharacterFormat` used by
1687/// `FormatRun`. `is_code_block` forces `monospace` as the font family for
1688/// every span inside a code block.
1689pub fn character_format_from_span(
1690    span: &ParsedSpan,
1691    is_code_block: bool,
1692) -> crate::format_runs::CharacterFormat {
1693    use crate::entities::CharVerticalAlignment;
1694    crate::format_runs::CharacterFormat {
1695        font_bold: if span.bold { Some(true) } else { None },
1696        font_italic: if span.italic { Some(true) } else { None },
1697        font_underline: if span.underline { Some(true) } else { None },
1698        font_strikeout: if span.strikeout { Some(true) } else { None },
1699        font_family: if span.code || is_code_block {
1700            Some("monospace".to_string())
1701        } else {
1702            None
1703        },
1704        anchor_href: span.link_href.clone(),
1705        is_anchor: if span.link_href.is_some() {
1706            Some(true)
1707        } else {
1708            None
1709        },
1710        vertical_alignment: if span.superscript {
1711            Some(CharVerticalAlignment::SuperScript)
1712        } else if span.subscript {
1713            Some(CharVerticalAlignment::SubScript)
1714        } else {
1715            None
1716        },
1717        ..Default::default()
1718    }
1719}
1720
1721/// Translate a slice of parsed spans into `(plain_text, format_runs)`.
1722///
1723/// One non-default span yields one `FormatRun`; spans with empty
1724/// `CharacterFormat` (no decoration, no link, no code) emit no run, since an
1725/// absent run means "inherit default formatting" in the new model. Adjacent
1726/// runs with identical formats are coalesced via `coalesce_in_place` so the
1727/// resulting vector satisfies `debug_assert_well_formed`.
1728///
1729/// Returns the concatenated `plain_text` of all spans and a sorted,
1730/// non-overlapping, coalesced `Vec<FormatRun>`. Both safe to feed straight
1731/// into the store under the dual-write bridge.
1732pub fn format_runs_from_spans(spans: &[ParsedSpan], is_code_block: bool) -> ParsedInline {
1733    use crate::format_runs::{
1734        CharacterFormat, FootnoteRefAnchor, FormatRun, ImageAnchor, coalesce_in_place,
1735    };
1736
1737    let mut plain_text = String::new();
1738    let mut runs: Vec<FormatRun> = Vec::new();
1739    let mut images: Vec<ImageAnchor> = Vec::new();
1740    let mut footnote_refs: Vec<FootnoteRefAnchor> = Vec::new();
1741    let default = CharacterFormat::default();
1742
1743    for span in spans {
1744        let byte_start = plain_text.len() as u32;
1745
1746        if let Some(label) = &span.footnote_ref {
1747            // A reference occupies one U+FFFC, exactly as an image does, so
1748            // every downstream offset treats the two alike.
1749            plain_text.push('\u{FFFC}');
1750            // Raised, always — whatever the surrounding run is doing.
1751            //
1752            // A footnote marker is superscript by definition, in every
1753            // typographic tradition and in every reader that renders djot. The
1754            // ambient `superscript` flag the span carries is the *prose's*, and
1755            // prose is not superscript, so taking it verbatim sets a note's
1756            // number on the baseline in the middle of a sentence — which reads
1757            // as a stray digit the writer typed rather than as a reference.
1758            //
1759            // It is set here, on the anchor, rather than at render time so that
1760            // every consumer agrees: the editor raises it, the exporters that
1761            // carry character formatting carry it, and the djot writer knows to
1762            // emit `[^label]` *without* wrapping it in `^…^` (see
1763            // `a_reference_is_not_wrapped_in_superscript_markers`).
1764            let mut format = character_format_from_span(span, is_code_block);
1765            format.vertical_alignment = Some(crate::entities::CharVerticalAlignment::SuperScript);
1766            footnote_refs.push(FootnoteRefAnchor {
1767                byte_offset: byte_start,
1768                label: label.clone(),
1769                format,
1770            });
1771            continue;
1772        }
1773
1774        if let Some(image) = &span.image {
1775            // An image occupies one U+FFFC in the text, exactly as
1776            // `insert_image` mirrors into the rope, so every downstream offset
1777            // calculation treats a parsed image and an inserted one alike.
1778            plain_text.push('\u{FFFC}');
1779            images.push(ImageAnchor {
1780                byte_offset: byte_start,
1781                name: image.src.clone(),
1782                alt: image.alt.clone(),
1783                width: image.width,
1784                height: image.height,
1785                quality: 100,
1786                format: character_format_from_span(span, is_code_block),
1787            });
1788            continue;
1789        }
1790
1791        plain_text.push_str(&span.text);
1792        let byte_end = plain_text.len() as u32;
1793        if byte_start == byte_end {
1794            continue;
1795        }
1796        let format = character_format_from_span(span, is_code_block);
1797        if format == default {
1798            continue;
1799        }
1800        runs.push(FormatRun {
1801            byte_start,
1802            byte_end,
1803            format,
1804        });
1805    }
1806    coalesce_in_place(&mut runs);
1807    ParsedInline {
1808        plain_text,
1809        runs,
1810        images,
1811        footnote_refs,
1812    }
1813}
1814
1815/// The three parallel things a block stores, as recovered from parsed spans.
1816///
1817/// Returned as a struct rather than a tuple because it grew a third member
1818/// (images) after nine call sites already destructured a pair — and every one
1819/// of those sites has to decide what to do with images, so a silent
1820/// tuple-arity change would have been the wrong kind of easy.
1821#[derive(Debug, Clone, Default)]
1822pub struct ParsedInline {
1823    pub plain_text: String,
1824    pub runs: Vec<crate::format_runs::FormatRun>,
1825    pub images: Vec<crate::format_runs::ImageAnchor>,
1826    pub footnote_refs: Vec<crate::format_runs::FootnoteRefAnchor>,
1827}
1828
1829// ─── Djot parsing ────────────────────────────────────────────────────
1830
1831/// Map a jotdown unordered/task bullet marker to a model `ListStyle`.
1832///
1833/// The mapping is a stable bijection (`-`↔Disc, `*`↔Circle, `+`↔Square) so the
1834/// djot exporter can recover the exact bullet character for a lossless
1835/// round-trip.
1836fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
1837    use jotdown::ListBulletType as B;
1838    match b {
1839        B::Dash => ListStyle::Disc,
1840        B::Star => ListStyle::Circle,
1841        B::Plus => ListStyle::Square,
1842    }
1843}
1844
1845/// Map a jotdown ordered-list numbering scheme to a model `ListStyle`.
1846fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
1847    use jotdown::OrderedListNumbering as N;
1848    match n {
1849        N::Decimal => ListStyle::Decimal,
1850        N::AlphaLower => ListStyle::LowerAlpha,
1851        N::AlphaUpper => ListStyle::UpperAlpha,
1852        N::RomanLower => ListStyle::LowerRoman,
1853        N::RomanUpper => ListStyle::UpperRoman,
1854    }
1855}
1856
1857/// Map a jotdown ordered-list delimiter to the `(prefix, suffix)` affixes
1858/// stored on the `List` entity (`1.` → `("", ".")`, `1)` → `("", ")")`,
1859/// `(1)` → `("(", ")")`).
1860fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
1861    use jotdown::OrderedListStyle as S;
1862    match style {
1863        S::Period => (String::new(), ".".to_string()),
1864        S::Paren => (String::new(), ")".to_string()),
1865        S::ParenParen => ("(".to_string(), ")".to_string()),
1866    }
1867}
1868
1869/// Optional block-level style attributes carried on a djot block through its
1870/// `{key=value}` block attributes. All `None` when the block has no such
1871/// attributes (or they were filtered out by [`DjotImportOptions`]).
1872#[derive(Debug, Clone, Default)]
1873struct DjotBlockStyle {
1874    alignment: Option<Alignment>,
1875    line_height: Option<i64>,
1876    non_breakable_lines: Option<bool>,
1877    page_break_before: Option<bool>,
1878    direction: Option<TextDirection>,
1879    background_color: Option<String>,
1880    top_margin: Option<i64>,
1881    text_indent: Option<i64>,
1882    semantic_role: Option<SemanticRole>,
1883}
1884
1885impl DjotBlockStyle {
1886    /// Overlay the `Some` fields of `other` onto `self`, leaving `self`'s
1887    /// existing values for any field `other` does not set. Used to combine a
1888    /// heading's enclosing-`Section` attributes with any on the heading itself.
1889    fn merge_from(&mut self, other: DjotBlockStyle) {
1890        if other.alignment.is_some() {
1891            self.alignment = other.alignment;
1892        }
1893        if other.line_height.is_some() {
1894            self.line_height = other.line_height;
1895        }
1896        if other.non_breakable_lines.is_some() {
1897            self.non_breakable_lines = other.non_breakable_lines;
1898        }
1899        if other.page_break_before.is_some() {
1900            self.page_break_before = other.page_break_before;
1901        }
1902        if other.direction.is_some() {
1903            self.direction = other.direction;
1904        }
1905        if other.background_color.is_some() {
1906            self.background_color = other.background_color;
1907        }
1908        if other.top_margin.is_some() {
1909            self.top_margin = other.top_margin;
1910        }
1911        if other.text_indent.is_some() {
1912            self.text_indent = other.text_indent;
1913        }
1914        if other.semantic_role.is_some() {
1915            self.semantic_role = other.semantic_role.clone();
1916        }
1917    }
1918}
1919
1920/// Read the round-tripped block-style attributes off a djot block's
1921/// [`jotdown::Attributes`], honouring the import [`DjotImportOptions`]. Keys are
1922/// the model field names (`alignment`, `line_height`, `direction`,
1923/// `non_breakable_lines`, `page_break_before`, `background_color`, `top_margin`,
1924/// `text_indent`, `semantic_role`); unrecognised values are ignored.
1925fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
1926    let mut style = DjotBlockStyle::default();
1927
1928    if opts.alignment
1929        && let Some(v) = attrs.get_value("alignment")
1930    {
1931        style.alignment = match v.to_string().as_str() {
1932            "left" => Some(Alignment::Left),
1933            "right" => Some(Alignment::Right),
1934            "center" => Some(Alignment::Center),
1935            "justify" => Some(Alignment::Justify),
1936            _ => None,
1937        };
1938    }
1939    if opts.line_height
1940        && let Some(v) = attrs.get_value("line_height")
1941    {
1942        style.line_height = v.to_string().parse::<i64>().ok();
1943    }
1944    if opts.direction
1945        && let Some(v) = attrs.get_value("direction")
1946    {
1947        style.direction = match v.to_string().as_str() {
1948            "ltr" => Some(TextDirection::LeftToRight),
1949            "rtl" => Some(TextDirection::RightToLeft),
1950            _ => None,
1951        };
1952    }
1953    if opts.non_breakable_lines
1954        && let Some(v) = attrs.get_value("non_breakable_lines")
1955    {
1956        style.non_breakable_lines = match v.to_string().as_str() {
1957            "true" => Some(true),
1958            "false" => Some(false),
1959            _ => None,
1960        };
1961    }
1962    if opts.page_break_before
1963        && let Some(v) = attrs.get_value("page_break_before")
1964    {
1965        style.page_break_before = match v.to_string().as_str() {
1966            "true" => Some(true),
1967            "false" => Some(false),
1968            _ => None,
1969        };
1970    }
1971    if opts.background_color
1972        && let Some(v) = attrs.get_value("background_color")
1973    {
1974        style.background_color = Some(v.to_string());
1975    }
1976    if opts.top_margin
1977        && let Some(v) = attrs.get_value("top_margin")
1978    {
1979        style.top_margin = v.to_string().parse::<i64>().ok();
1980    }
1981    if opts.text_indent
1982        && let Some(v) = attrs.get_value("text_indent")
1983    {
1984        style.text_indent = v.to_string().parse::<i64>().ok();
1985    }
1986    if opts.semantic_role
1987        && let Some(v) = attrs.get_value("semantic_role")
1988    {
1989        style.semantic_role = match v.to_string().as_str() {
1990            "epigraph" => Some(SemanticRole::Epigraph),
1991            // An unknown role is dropped, not guessed at — the same way an unknown
1992            // alignment value above is. A future role read by an older build then
1993            // degrades to a plain blockquote, which is what it looks like anyway.
1994            _ => None,
1995        };
1996    }
1997
1998    style
1999}
2000
2001/// Push a finished block into `elements`, applying the djot block-level fields
2002/// plus any round-tripped block-style attributes carried in `style`.
2003#[allow(clippy::too_many_arguments)]
2004fn djot_push_block(
2005    elements: &mut Vec<ParsedElement>,
2006    spans: Vec<ParsedSpan>,
2007    heading_level: Option<i64>,
2008    list_style: Option<ListStyle>,
2009    list_indent: u32,
2010    list_prefix: String,
2011    list_suffix: String,
2012    marker: Option<MarkerType>,
2013    is_code_block: bool,
2014    code_language: Option<String>,
2015    blockquote_depth: u32,
2016    style: DjotBlockStyle,
2017) {
2018    elements.push(ParsedElement::Block(ParsedBlock {
2019        spans,
2020        heading_level,
2021        list_style,
2022        list_indent,
2023        list_prefix,
2024        list_suffix,
2025        marker,
2026        is_code_block,
2027        code_language,
2028        blockquote_depth,
2029        line_height: style.line_height,
2030        non_breakable_lines: style.non_breakable_lines,
2031        page_break_before: style.page_break_before,
2032        direction: style.direction,
2033        background_color: style.background_color,
2034        alignment: style.alignment,
2035        top_margin: style.top_margin,
2036        text_indent: style.text_indent,
2037        semantic_role: style.semantic_role.clone(),
2038    }));
2039}
2040
2041/// Parse djot source into the shared [`ParsedElement`] intermediate, mirroring
2042/// [`parse_markdown`]. Uses the [`jotdown`] pull parser.
2043///
2044/// Constructs the document model cannot represent are dropped, and their text
2045/// content is discarded so it never leaks into the document: footnotes, math,
2046/// fenced divs, raw blocks/inline, thematic breaks, description lists,
2047/// captions, symbols, link-reference definitions, and highlight/`mark`. Inline
2048/// images keep their alt text as plain text (the image itself is not modelled),
2049/// matching the Markdown importer. Smart-punctuation events are normalised to
2050/// their canonical Unicode characters so the model→djot→model round-trip is a
2051/// fixpoint.
2052///
2053/// Standalone paragraphs and headings additionally carry the optional
2054/// block-style attributes selected by `options` — paragraph alignment, line
2055/// height, text direction, non-breakable lines and background color — read from
2056/// djot `{key=value}` block attributes (see [`DjotImportOptions`]). List items,
2057/// code blocks and table cells normalise their block styling away.
2058///
2059/// Known model limitations (normalised, not preserved on round-trip):
2060/// ordered-list start number, table column alignment, and list tight/loose.
2061pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
2062    use jotdown::{Container as C, Event as E, ListKind, Parser};
2063
2064    let mut elements: Vec<ParsedElement> = Vec::new();
2065    let mut current_spans: Vec<ParsedSpan> = Vec::new();
2066    let mut current_heading: Option<i64> = None;
2067    let mut is_code_block = false;
2068    let mut code_language: Option<String> = None;
2069    let mut blockquote_depth: u32 = 0;
2070    // Block-style attributes captured from a standalone paragraph/heading's djot
2071    // `{…}` block attributes, consumed when that block is flushed.
2072    let mut pending_style = DjotBlockStyle::default();
2073
2074    // Inline formatting state.
2075    let mut bold = false;
2076    let mut italic = false;
2077    let mut underline = false;
2078    let mut strikeout = false;
2079    let mut code = false;
2080    let mut superscript = false;
2081    let mut subscript = false;
2082    let mut link_href: Option<String> = None;
2083    // Set between an image's Start and End. Its alt text arrives as ordinary
2084    // `Str` events in between, so it has to be captured rather than emitted.
2085    let mut pending_image: Option<ParsedImage> = None;
2086
2087    // List nesting: each entry is (style, prefix, suffix); depth = indent + 1.
2088    let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
2089    // Context applied to the next flushed block while inside a list item.
2090    let mut cur_list_style: Option<ListStyle> = None;
2091    let mut cur_list_prefix = String::new();
2092    let mut cur_list_suffix = String::new();
2093    let mut cur_list_indent: u32 = 0;
2094    let mut cur_marker: Option<MarkerType> = None;
2095
2096    // Table accumulation.
2097    let mut in_table_cell = false;
2098    let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
2099    let mut current_row: Vec<ParsedTableCell> = Vec::new();
2100    let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
2101    let mut table_header_rows: usize = 0;
2102    let mut row_is_head = false;
2103
2104    // Subtree-skip depth for unrepresentable containers (their entire content
2105    // is dropped). Incremented on the dropped container's `Start` and on every
2106    // nested `Start`; decremented on every `End`.
2107    let mut skip_depth: u32 = 0;
2108
2109    // The label and element index of the footnote definition currently open,
2110    // if any. Djot has no footnote inside a footnote, so one slot is enough
2111    // where the dropped containers need a depth counter.
2112    let mut footnote_open: Option<(String, usize)> = None;
2113
2114    // Push one inline span carrying the current formatting state into the
2115    // active sink (table cell or block). A macro (not a closure) to avoid
2116    // borrowing `current_spans`/`current_cell_spans` across the formatting
2117    // state reads.
2118    macro_rules! push_text {
2119        ($t:expr) => {{
2120            // Alt text belongs to the image, not to the paragraph. While an
2121            // image is open every text event is diverted into its description,
2122            // which is what keeps a photo's caption out of the manuscript's
2123            // word count and out of the search corpus.
2124            if let Some(img) = pending_image.as_mut() {
2125                img.alt.push_str(($t).as_ref());
2126            } else {
2127                let sp = ParsedSpan {
2128                    text: ($t).to_string(),
2129                    bold,
2130                    italic,
2131                    underline,
2132                    strikeout,
2133                    code,
2134                    superscript,
2135                    subscript,
2136                    link_href: link_href.clone(),
2137                    image: None,
2138                    footnote_ref: None,
2139                };
2140                if in_table_cell {
2141                    current_cell_spans.push(sp);
2142                } else {
2143                    current_spans.push(sp);
2144                }
2145            }
2146        }};
2147    }
2148
2149    // Push a completed inline image span into the active sink.
2150    macro_rules! push_image {
2151        ($img:expr) => {{
2152            let sp = ParsedSpan {
2153                text: String::new(),
2154                bold,
2155                italic,
2156                underline,
2157                strikeout,
2158                code,
2159                superscript,
2160                subscript,
2161                link_href: link_href.clone(),
2162                image: Some($img),
2163                footnote_ref: None,
2164            };
2165            if in_table_cell {
2166                current_cell_spans.push(sp);
2167            } else {
2168                current_spans.push(sp);
2169            }
2170        }};
2171    }
2172
2173    // Enter a list item, flushing any unterminated inline content first and
2174    // capturing the list context + task marker for the item's block.
2175    macro_rules! enter_item {
2176        ($marker:expr) => {{
2177            if !current_spans.is_empty() {
2178                djot_push_block(
2179                    &mut elements,
2180                    std::mem::take(&mut current_spans),
2181                    None,
2182                    cur_list_style.clone(),
2183                    cur_list_indent,
2184                    cur_list_prefix.clone(),
2185                    cur_list_suffix.clone(),
2186                    cur_marker.clone(),
2187                    false,
2188                    None,
2189                    blockquote_depth,
2190                    DjotBlockStyle::default(),
2191                );
2192            }
2193            let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
2194                ListStyle::Disc,
2195                String::new(),
2196                String::new(),
2197            ));
2198            cur_list_style = Some(style);
2199            cur_list_prefix = prefix;
2200            cur_list_suffix = suffix;
2201            cur_list_indent = list_stack.len().saturating_sub(1) as u32;
2202            cur_marker = $marker;
2203        }};
2204    }
2205
2206    for event in Parser::new(djot) {
2207        if skip_depth > 0 {
2208            match event {
2209                E::Start(..) => skip_depth += 1,
2210                E::End(_) => skip_depth -= 1,
2211                _ => {}
2212            }
2213            continue;
2214        }
2215
2216        match event {
2217            // ── Transparent wrappers (unwrap, keep content) ──
2218            E::Start(C::Document, _) | E::End(C::Document) => {}
2219            E::Start(C::Section { .. }, attrs) => {
2220                // A heading's block attributes attach to its enclosing Section,
2221                // not the heading itself; capture them for the heading's flush.
2222                if list_stack.is_empty() {
2223                    pending_style.merge_from(block_attrs_to_style(&attrs, options));
2224                }
2225            }
2226            E::End(C::Section { .. }) => {}
2227            E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
2228
2229            // ── Blockquote ──
2230            E::Start(C::Blockquote, _) => blockquote_depth += 1,
2231            E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
2232
2233            // ── Lists ──
2234            E::Start(C::List { kind, .. }, _) => {
2235                let (style, prefix, suffix) = match kind {
2236                    ListKind::Unordered(b) | ListKind::Task(b) => {
2237                        (djot_bullet_style(b), String::new(), String::new())
2238                    }
2239                    ListKind::Ordered {
2240                        numbering, style, ..
2241                    } => {
2242                        let (p, s) = djot_ordered_affixes(style);
2243                        (djot_ordered_style(numbering), p, s)
2244                    }
2245                };
2246                list_stack.push((style, prefix, suffix));
2247            }
2248            E::End(C::List { .. }) => {
2249                list_stack.pop();
2250                cur_list_style = None;
2251                cur_marker = None;
2252            }
2253            E::Start(C::ListItem, _) => enter_item!(None),
2254            E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
2255                MarkerType::Checked
2256            } else {
2257                MarkerType::Unchecked
2258            })),
2259            E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
2260                // Tight item without a wrapping paragraph (defensive flush).
2261                if !current_spans.is_empty() {
2262                    djot_push_block(
2263                        &mut elements,
2264                        std::mem::take(&mut current_spans),
2265                        None,
2266                        cur_list_style.clone(),
2267                        cur_list_indent,
2268                        cur_list_prefix.clone(),
2269                        cur_list_suffix.clone(),
2270                        cur_marker.clone(),
2271                        false,
2272                        None,
2273                        blockquote_depth,
2274                        DjotBlockStyle::default(),
2275                    );
2276                }
2277                cur_list_style = None;
2278                cur_marker = None;
2279            }
2280
2281            // ── Headings, paragraphs, code blocks ──
2282            E::Start(C::Heading { level, .. }, attrs) => {
2283                current_heading = Some(level as i64);
2284                // The block-style attributes live on the enclosing Section;
2285                // merge any placed directly on the heading without clearing them.
2286                pending_style.merge_from(block_attrs_to_style(&attrs, options));
2287            }
2288            E::End(C::Heading { .. }) => {
2289                djot_push_block(
2290                    &mut elements,
2291                    std::mem::take(&mut current_spans),
2292                    current_heading.take(),
2293                    None,
2294                    0,
2295                    String::new(),
2296                    String::new(),
2297                    None,
2298                    false,
2299                    None,
2300                    blockquote_depth,
2301                    std::mem::take(&mut pending_style),
2302                );
2303            }
2304            E::Start(C::Paragraph, attrs) => {
2305                current_heading = None;
2306                // Block attributes only apply to standalone paragraphs;
2307                // list-item paragraphs normalise their styling away (matching
2308                // the exporter).
2309                pending_style = if list_stack.is_empty() {
2310                    block_attrs_to_style(&attrs, options)
2311                } else {
2312                    DjotBlockStyle::default()
2313                };
2314            }
2315            E::End(C::Paragraph) => {
2316                if !current_spans.is_empty() {
2317                    djot_push_block(
2318                        &mut elements,
2319                        std::mem::take(&mut current_spans),
2320                        None,
2321                        cur_list_style.clone(),
2322                        cur_list_indent,
2323                        cur_list_prefix.clone(),
2324                        cur_list_suffix.clone(),
2325                        cur_marker.clone(),
2326                        false,
2327                        None,
2328                        blockquote_depth,
2329                        std::mem::take(&mut pending_style),
2330                    );
2331                }
2332                cur_list_style = None;
2333                cur_marker = None;
2334            }
2335            E::Start(C::CodeBlock { language }, _) => {
2336                is_code_block = true;
2337                code_language = if language.is_empty() {
2338                    None
2339                } else {
2340                    Some(language.to_string())
2341                };
2342            }
2343            E::End(C::CodeBlock { .. }) => {
2344                // Strip the single trailing newline jotdown appends.
2345                if let Some(last) = current_spans.last_mut()
2346                    && last.text.ends_with('\n')
2347                {
2348                    last.text.pop();
2349                }
2350                djot_push_block(
2351                    &mut elements,
2352                    std::mem::take(&mut current_spans),
2353                    None,
2354                    None,
2355                    0,
2356                    String::new(),
2357                    String::new(),
2358                    None,
2359                    true,
2360                    code_language.take(),
2361                    blockquote_depth,
2362                    DjotBlockStyle::default(),
2363                );
2364                is_code_block = false;
2365            }
2366
2367            // ── Tables ──
2368            E::Start(C::Table, _) => {
2369                table_rows.clear();
2370                current_row.clear();
2371                current_cell_spans.clear();
2372                table_header_rows = 0;
2373            }
2374            E::End(C::Table) => {
2375                elements.push(ParsedElement::Table(ParsedTable {
2376                    header_rows: table_header_rows,
2377                    rows: std::mem::take(&mut table_rows),
2378                    blockquote_depth,
2379                }));
2380            }
2381            E::Start(C::TableRow { head }, _) => {
2382                row_is_head = head;
2383                current_row.clear();
2384            }
2385            E::End(C::TableRow { .. }) => {
2386                if row_is_head {
2387                    table_header_rows += 1;
2388                }
2389                table_rows.push(std::mem::take(&mut current_row));
2390            }
2391            E::Start(C::TableCell { .. }, _) => {
2392                in_table_cell = true;
2393                current_cell_spans.clear();
2394            }
2395            E::End(C::TableCell { .. }) => {
2396                in_table_cell = false;
2397                current_row.push(ParsedTableCell {
2398                    spans: std::mem::take(&mut current_cell_spans),
2399                });
2400            }
2401
2402            // ── Inline formatting ──
2403            E::Start(C::Strong, _) => bold = true,
2404            E::End(C::Strong) => bold = false,
2405            E::Start(C::Emphasis, _) => italic = true,
2406            E::End(C::Emphasis) => italic = false,
2407            E::Start(C::Verbatim, _) => code = true,
2408            E::End(C::Verbatim) => code = false,
2409            E::Start(C::Superscript, _) => superscript = true,
2410            E::End(C::Superscript) => superscript = false,
2411            E::Start(C::Subscript, _) => subscript = true,
2412            E::End(C::Subscript) => subscript = false,
2413            E::Start(C::Insert, _) => underline = true,
2414            E::End(C::Insert) => underline = false,
2415            E::Start(C::Delete, _) => strikeout = true,
2416            E::End(C::Delete) => strikeout = false,
2417            // Highlight/mark and bare spans have no model field — keep the text.
2418            E::Start(C::Mark, _) | E::End(C::Mark) => {}
2419            E::Start(C::Span, _) | E::End(C::Span) => {}
2420            E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
2421            E::End(C::Link(..)) => link_href = None,
2422            // Inline images. Djot writes display size as inline attributes
2423            // (`![alt](src){width=800 height=600}`), which jotdown hands over
2424            // on the `Start` event — verified against jotdown 0.10, including
2425            // quoted values and images mid-sentence.
2426            E::Start(C::Image(src, _), attrs) => {
2427                let attr_num = |key: &str| -> i64 {
2428                    attrs
2429                        .get_value(key)
2430                        .map(|v| v.to_string())
2431                        .and_then(|v| v.trim().parse::<i64>().ok())
2432                        .filter(|n| *n > 0)
2433                        .unwrap_or(0)
2434                };
2435                pending_image = Some(ParsedImage {
2436                    src: src.to_string(),
2437                    alt: String::new(),
2438                    width: attr_num("width"),
2439                    height: attr_num("height"),
2440                });
2441            }
2442            E::End(C::Image(..)) => {
2443                if let Some(img) = pending_image.take() {
2444                    push_image!(img);
2445                }
2446            }
2447
2448            // ── Footnote definitions ──
2449            //
2450            // The body is ordinary block content, so it is parsed by the
2451            // ordinary machinery: flush whatever inline run is open, note where
2452            // this definition's blocks start, and let them accumulate. `End`
2453            // lifts them back out. Nesting cannot occur — djot has no footnote
2454            // inside a footnote — so one mark suffices where the dropped
2455            // containers below need a depth counter.
2456            E::Start(C::Footnote { label }, _) => {
2457                if !current_spans.is_empty() {
2458                    djot_push_block(
2459                        &mut elements,
2460                        std::mem::take(&mut current_spans),
2461                        None,
2462                        cur_list_style.clone(),
2463                        cur_list_indent,
2464                        cur_list_prefix.clone(),
2465                        cur_list_suffix.clone(),
2466                        cur_marker.clone(),
2467                        false,
2468                        None,
2469                        blockquote_depth,
2470                        DjotBlockStyle::default(),
2471                    );
2472                }
2473                footnote_open = Some((label.to_string(), elements.len()));
2474            }
2475            E::End(C::Footnote { .. }) => {
2476                if !current_spans.is_empty() {
2477                    djot_push_block(
2478                        &mut elements,
2479                        std::mem::take(&mut current_spans),
2480                        None,
2481                        cur_list_style.clone(),
2482                        cur_list_indent,
2483                        cur_list_prefix.clone(),
2484                        cur_list_suffix.clone(),
2485                        cur_marker.clone(),
2486                        false,
2487                        None,
2488                        blockquote_depth,
2489                        DjotBlockStyle::default(),
2490                    );
2491                }
2492                if let Some((label, start)) = footnote_open.take() {
2493                    let blocks: Vec<ParsedBlock> = elements
2494                        .drain(start..)
2495                        .filter_map(|e| match e {
2496                            ParsedElement::Block(b) => Some(b),
2497                            // A table inside a footnote is not representable as
2498                            // note content; its cells would have to become
2499                            // blocks and lose their structure either way.
2500                            _ => None,
2501                        })
2502                        .collect();
2503                    elements.push(ParsedElement::FootnoteDefinition { label, blocks });
2504                }
2505            }
2506
2507            // ── Unrepresentable containers: drop the entire subtree ──
2508            E::Start(
2509                C::Math { .. }
2510                | C::RawBlock { .. }
2511                | C::RawInline { .. }
2512                | C::DescriptionList
2513                | C::DescriptionDetails
2514                | C::DescriptionTerm
2515                | C::Caption
2516                | C::LinkDefinition { .. },
2517                _,
2518            ) => skip_depth = 1,
2519
2520            // ── Text + atoms ──
2521            E::Str(s) => push_text!(s.as_ref()),
2522            E::Softbreak => push_text!(" "),
2523            E::LeftSingleQuote => push_text!("\u{2018}"),
2524            E::RightSingleQuote => push_text!("\u{2019}"),
2525            E::LeftDoubleQuote => push_text!("\u{201C}"),
2526            E::RightDoubleQuote => push_text!("\u{201D}"),
2527            E::Ellipsis => push_text!("\u{2026}"),
2528            E::EnDash => push_text!("\u{2013}"),
2529            E::EmDash => push_text!("\u{2014}"),
2530            E::NonBreakingSpace => push_text!("\u{00A0}"),
2531            E::Hardbreak => {
2532                if in_table_cell {
2533                    push_text!(" ");
2534                } else if !current_spans.is_empty() {
2535                    // Mirrors the Markdown importer: a hard break splits the
2536                    // paragraph into a new block.
2537                    djot_push_block(
2538                        &mut elements,
2539                        std::mem::take(&mut current_spans),
2540                        None,
2541                        cur_list_style.clone(),
2542                        cur_list_indent,
2543                        cur_list_prefix.clone(),
2544                        cur_list_suffix.clone(),
2545                        cur_marker.clone(),
2546                        is_code_block,
2547                        code_language.clone(),
2548                        blockquote_depth,
2549                        pending_style.clone(),
2550                    );
2551                }
2552            }
2553            // A footnote reference. jotdown emits this purely syntactically —
2554            // it never checks that a matching `[^label]:` exists anywhere — so a
2555            // reference whose definition lives outside this document (the normal
2556            // state for a host that owns note bodies itself) arrives here just
2557            // the same, and must survive.
2558            E::FootnoteReference(label) => {
2559                let sp = ParsedSpan {
2560                    text: String::new(),
2561                    bold,
2562                    italic,
2563                    underline,
2564                    strikeout,
2565                    code,
2566                    superscript,
2567                    subscript,
2568                    link_href: link_href.clone(),
2569                    image: None,
2570                    footnote_ref: Some(label.to_string()),
2571                };
2572                if in_table_cell {
2573                    current_cell_spans.push(sp);
2574                } else {
2575                    current_spans.push(sp);
2576                }
2577            }
2578            // Symbols, escapes, blanklines, thematic breaks and dangling block
2579            // attributes carry no representable content.
2580            E::Symbol(_) => {}
2581            E::Escape | E::Blankline => {}
2582            E::ThematicBreak(_) | E::Attributes(_) => {}
2583
2584            // Ends of dropped containers (never reached at skip_depth 0) and any
2585            // future variants.
2586            _ => {}
2587        }
2588    }
2589
2590    // Flush any trailing inline content (defensive — Document End closes blocks).
2591    if !current_spans.is_empty() {
2592        djot_push_block(
2593            &mut elements,
2594            std::mem::take(&mut current_spans),
2595            current_heading.take(),
2596            cur_list_style.clone(),
2597            cur_list_indent,
2598            cur_list_prefix.clone(),
2599            cur_list_suffix.clone(),
2600            cur_marker.clone(),
2601            is_code_block,
2602            code_language.take(),
2603            blockquote_depth,
2604            std::mem::take(&mut pending_style),
2605        );
2606    }
2607
2608    // An empty document still yields a single empty paragraph (matches
2609    // `parse_markdown`).
2610    if elements.is_empty() {
2611        djot_push_block(
2612            &mut elements,
2613            vec![ParsedSpan {
2614                text: String::new(),
2615                ..Default::default()
2616            }],
2617            None,
2618            None,
2619            0,
2620            String::new(),
2621            String::new(),
2622            None,
2623            false,
2624            None,
2625            0,
2626            DjotBlockStyle::default(),
2627        );
2628    }
2629
2630    elements
2631}
2632
2633#[cfg(test)]
2634mod tests {
2635    use super::*;
2636
2637    /// Helper: flatten parse_markdown output to blocks for tests that don't care about tables.
2638    fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
2639        ParsedElement::flatten_to_blocks(parse_markdown(md))
2640    }
2641
2642    #[test]
2643    fn test_parse_markdown_simple_paragraph() {
2644        let blocks = parse_markdown_blocks("Hello **world**");
2645        assert_eq!(blocks.len(), 1);
2646        assert!(blocks[0].spans.len() >= 2);
2647        // "Hello " is plain, "world" is bold
2648        let plain_span = blocks[0]
2649            .spans
2650            .iter()
2651            .find(|s| s.text.contains("Hello"))
2652            .unwrap();
2653        assert!(!plain_span.bold);
2654        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2655        assert!(bold_span.bold);
2656    }
2657
2658    #[test]
2659    fn test_parse_markdown_heading() {
2660        let blocks = parse_markdown_blocks("# Title");
2661        assert_eq!(blocks.len(), 1);
2662        assert_eq!(blocks[0].heading_level, Some(1));
2663        assert_eq!(blocks[0].spans[0].text, "Title");
2664    }
2665
2666    #[test]
2667    fn test_parse_markdown_list() {
2668        let blocks = parse_markdown_blocks("- item1\n- item2");
2669        assert!(blocks.len() >= 2);
2670        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2671        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2672    }
2673
2674    /// Helper: extract (is_table, blockquote_depth) per element for nesting assertions.
2675    fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
2676        elements
2677            .iter()
2678            .map(|e| match e {
2679                ParsedElement::Block(b) => (false, b.blockquote_depth),
2680                ParsedElement::Table(t) => (true, t.blockquote_depth),
2681                // Definitions carry no blockquote nesting of their own; this
2682                // helper exists for the nesting assertions and never sees one.
2683                ParsedElement::FootnoteDefinition { .. } => (false, 0),
2684            })
2685            .collect()
2686    }
2687
2688    #[test]
2689    fn test_parse_markdown_table_in_blockquote_records_depth() {
2690        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2691        assert_eq!(element_depths(&elements), vec![(true, 1)]);
2692    }
2693
2694    #[test]
2695    fn test_parse_markdown_text_then_table_in_blockquote() {
2696        let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
2697        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
2698    }
2699
2700    #[test]
2701    fn test_parse_markdown_table_after_blockquote_closes() {
2702        let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
2703        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
2704    }
2705
2706    #[test]
2707    fn test_parse_markdown_table_in_nested_blockquote() {
2708        let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
2709        assert_eq!(element_depths(&elements), vec![(true, 2)]);
2710    }
2711
2712    #[test]
2713    fn test_parse_markdown_list_in_blockquote_records_depth() {
2714        let elements = parse_markdown("> - item1\n> - item2");
2715        let depths = element_depths(&elements);
2716        assert_eq!(depths, vec![(false, 1), (false, 1)]);
2717        for e in &elements {
2718            if let ParsedElement::Block(b) = e {
2719                assert_eq!(b.list_style, Some(ListStyle::Disc));
2720            }
2721        }
2722    }
2723
2724    #[test]
2725    fn test_parse_html_table_in_blockquote_records_depth() {
2726        let elements = parse_html_elements(
2727            "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
2728        );
2729        assert_eq!(element_depths(&elements), vec![(true, 1)]);
2730    }
2731
2732    #[test]
2733    fn test_parse_html_table_after_blockquote() {
2734        let elements = parse_html_elements(
2735            "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
2736        );
2737        let depths = element_depths(&elements);
2738        // The blockquote paragraph carries depth 1; the table is outside (depth 0).
2739        assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
2740        assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
2741    }
2742
2743    #[test]
2744    fn test_flatten_to_blocks_propagates_blockquote_depth() {
2745        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
2746        let blocks = ParsedElement::flatten_to_blocks(elements);
2747        assert!(!blocks.is_empty());
2748        for b in &blocks {
2749            assert_eq!(b.blockquote_depth, 1);
2750        }
2751    }
2752
2753    #[test]
2754    fn test_parse_html_simple() {
2755        let blocks = parse_html("<p>Hello <b>world</b></p>");
2756        assert_eq!(blocks.len(), 1);
2757        assert!(blocks[0].spans.len() >= 2);
2758        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
2759        assert!(bold_span.bold);
2760    }
2761
2762    #[test]
2763    fn test_parse_html_multiple_paragraphs() {
2764        let blocks = parse_html("<p>A</p><p>B</p>");
2765        assert_eq!(blocks.len(), 2);
2766    }
2767
2768    #[test]
2769    fn test_parse_html_heading() {
2770        let blocks = parse_html("<h2>Subtitle</h2>");
2771        assert_eq!(blocks.len(), 1);
2772        assert_eq!(blocks[0].heading_level, Some(2));
2773    }
2774
2775    #[test]
2776    fn test_parse_html_list() {
2777        let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2778        assert!(blocks.len() >= 2);
2779        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2780    }
2781
2782    #[test]
2783    fn test_parse_markdown_code_block() {
2784        let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2785        assert_eq!(blocks.len(), 1);
2786        assert!(blocks[0].is_code_block);
2787        assert!(blocks[0].spans[0].code);
2788        // pulldown-cmark appends a trailing \n to code block text — verify it's stripped
2789        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2790        assert_eq!(
2791            text, "fn main() {}",
2792            "code block text should not have trailing newline"
2793        );
2794    }
2795
2796    #[test]
2797    fn test_parse_markdown_nested_formatting() {
2798        let blocks = parse_markdown_blocks("***bold italic***");
2799        assert_eq!(blocks.len(), 1);
2800        let span = &blocks[0].spans[0];
2801        assert!(span.bold);
2802        assert!(span.italic);
2803    }
2804
2805    #[test]
2806    fn test_parse_markdown_link() {
2807        let blocks = parse_markdown_blocks("[click](http://example.com)");
2808        assert_eq!(blocks.len(), 1);
2809        let span = &blocks[0].spans[0];
2810        assert_eq!(span.text, "click");
2811        assert_eq!(span.link_href, Some("http://example.com".to_string()));
2812    }
2813
2814    #[test]
2815    fn test_parse_markdown_empty() {
2816        let blocks = parse_markdown_blocks("");
2817        assert_eq!(blocks.len(), 1);
2818        assert!(blocks[0].spans[0].text.is_empty());
2819    }
2820
2821    #[test]
2822    fn test_parse_html_empty() {
2823        let blocks = parse_html("");
2824        assert_eq!(blocks.len(), 1);
2825        assert!(blocks[0].spans[0].text.is_empty());
2826    }
2827
2828    #[test]
2829    fn test_parse_html_nested_formatting() {
2830        let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
2831        assert_eq!(blocks.len(), 1);
2832        let span = &blocks[0].spans[0];
2833        assert!(span.bold);
2834        assert!(span.italic);
2835    }
2836
2837    #[test]
2838    fn test_parse_html_link() {
2839        let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
2840        assert_eq!(blocks.len(), 1);
2841        let span = &blocks[0].spans[0];
2842        assert_eq!(span.text, "click");
2843        assert_eq!(span.link_href, Some("http://example.com".to_string()));
2844    }
2845
2846    #[test]
2847    fn test_parse_html_ordered_list() {
2848        let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
2849        assert!(blocks.len() >= 2);
2850        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2851    }
2852
2853    #[test]
2854    fn test_parse_markdown_ordered_list() {
2855        let blocks = parse_markdown_blocks("1. first\n2. second");
2856        assert!(blocks.len() >= 2);
2857        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2858    }
2859
2860    #[test]
2861    fn test_parse_html_blockquote_nested() {
2862        let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
2863        assert!(blocks.len() >= 3);
2864    }
2865
2866    #[test]
2867    fn test_parse_block_styles_line_height() {
2868        let styles = parse_block_styles("line-height: 1.5");
2869        assert_eq!(styles.line_height, Some(1500));
2870    }
2871
2872    #[test]
2873    fn test_parse_block_styles_direction_rtl() {
2874        let styles = parse_block_styles("direction: rtl");
2875        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2876    }
2877
2878    #[test]
2879    fn test_parse_block_styles_background_color() {
2880        let styles = parse_block_styles("background-color: #ff0000");
2881        assert_eq!(styles.background_color, Some("#ff0000".to_string()));
2882    }
2883
2884    #[test]
2885    fn test_parse_block_styles_white_space_pre() {
2886        let styles = parse_block_styles("white-space: pre");
2887        assert_eq!(styles.non_breakable_lines, Some(true));
2888    }
2889
2890    #[test]
2891    fn test_parse_block_styles_multiple() {
2892        let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
2893        assert_eq!(styles.line_height, Some(2000));
2894        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2895        assert_eq!(styles.background_color, Some("blue".to_string()));
2896    }
2897
2898    #[test]
2899    fn test_parse_html_block_styles_extracted() {
2900        let blocks = parse_html(
2901            r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
2902        );
2903        assert_eq!(blocks.len(), 1);
2904        assert_eq!(blocks[0].line_height, Some(1500));
2905        assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
2906        assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
2907    }
2908
2909    #[test]
2910    fn test_parse_html_white_space_pre() {
2911        let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
2912        assert_eq!(blocks.len(), 1);
2913        assert_eq!(blocks[0].non_breakable_lines, Some(true));
2914    }
2915
2916    #[test]
2917    fn test_parse_html_no_styles_returns_none() {
2918        let blocks = parse_html("<p>plain</p>");
2919        assert_eq!(blocks.len(), 1);
2920        assert_eq!(blocks[0].line_height, None);
2921        assert_eq!(blocks[0].direction, None);
2922        assert_eq!(blocks[0].background_color, None);
2923        assert_eq!(blocks[0].non_breakable_lines, None);
2924    }
2925
2926    #[test]
2927    fn test_parse_markdown_nested_list_indent() {
2928        let md = "- top\n  - nested\n    - deep";
2929        let blocks = parse_markdown_blocks(md);
2930        assert_eq!(blocks.len(), 3);
2931        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2932        assert_eq!(blocks[0].list_indent, 0);
2933        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2934        assert_eq!(blocks[1].list_indent, 1);
2935        assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
2936        assert_eq!(blocks[2].list_indent, 2);
2937    }
2938
2939    #[test]
2940    fn test_parse_markdown_nested_ordered_list_indent() {
2941        let md = "1. first\n   1. nested\n   2. nested2";
2942        let blocks = parse_markdown_blocks(md);
2943        assert_eq!(blocks.len(), 3);
2944        assert_eq!(blocks[0].list_indent, 0);
2945        assert_eq!(blocks[1].list_indent, 1);
2946        assert_eq!(blocks[2].list_indent, 1);
2947    }
2948
2949    #[test]
2950    fn test_parse_html_nested_list_indent() {
2951        let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
2952        let blocks = parse_html(html);
2953        assert!(blocks.len() >= 2);
2954        assert_eq!(blocks[0].list_indent, 0);
2955        assert_eq!(blocks[1].list_indent, 1);
2956    }
2957
2958    #[test]
2959    fn test_parse_markdown_table() {
2960        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
2961        let elements = parse_markdown(md);
2962        assert_eq!(elements.len(), 1);
2963        match &elements[0] {
2964            ParsedElement::Table(table) => {
2965                assert_eq!(table.header_rows, 1);
2966                assert_eq!(table.rows.len(), 2); // 1 header + 1 body
2967                // Header row
2968                assert_eq!(table.rows[0].len(), 2);
2969                assert_eq!(table.rows[0][0].spans[0].text, "A");
2970                assert_eq!(table.rows[0][1].spans[0].text, "B");
2971                // Body row
2972                assert_eq!(table.rows[1].len(), 2);
2973                assert_eq!(table.rows[1][0].spans[0].text, "1");
2974                assert_eq!(table.rows[1][1].spans[0].text, "2");
2975            }
2976            _ => panic!("Expected ParsedElement::Table"),
2977        }
2978    }
2979
2980    #[test]
2981    fn test_parse_markdown_table_with_formatting() {
2982        let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
2983        let elements = parse_markdown(md);
2984        assert_eq!(elements.len(), 1);
2985        match &elements[0] {
2986            ParsedElement::Table(table) => {
2987                assert_eq!(table.rows.len(), 2);
2988                // Header: bold cell
2989                assert!(table.rows[0][0].spans[0].bold);
2990                // Header: code cell
2991                assert!(table.rows[0][1].spans[0].code);
2992                // Header: italic cell
2993                assert!(table.rows[0][2].spans[0].italic);
2994                // Body: strikeout cell
2995                assert!(table.rows[1][0].spans[0].strikeout);
2996                // Body: link cell
2997                assert_eq!(
2998                    table.rows[1][2].spans[0].link_href,
2999                    Some("http://x.com".to_string())
3000                );
3001            }
3002            _ => panic!("Expected ParsedElement::Table"),
3003        }
3004    }
3005
3006    #[test]
3007    fn test_parse_markdown_mixed_content_with_table() {
3008        let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
3009        let elements = parse_markdown(md);
3010        assert_eq!(elements.len(), 3);
3011        assert!(matches!(&elements[0], ParsedElement::Block(_)));
3012        assert!(matches!(&elements[1], ParsedElement::Table(_)));
3013        assert!(matches!(&elements[2], ParsedElement::Block(_)));
3014    }
3015}
3016
3017#[cfg(test)]
3018mod djot_tests {
3019    use super::*;
3020    use crate::entities::MarkerType;
3021
3022    fn blocks(d: &str) -> Vec<ParsedBlock> {
3023        ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
3024    }
3025
3026    fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
3027        b.spans.iter().find(|s| pred(s)).expect("span not found")
3028    }
3029
3030    #[test]
3031    fn paragraph_bold_italic() {
3032        let b = blocks("normal *bold* _italic_");
3033        assert_eq!(b.len(), 1);
3034        assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
3035        assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
3036    }
3037
3038    #[test]
3039    fn heading_levels() {
3040        assert_eq!(blocks("# H1")[0].heading_level, Some(1));
3041        assert_eq!(blocks("### H3")[0].heading_level, Some(3));
3042        assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
3043    }
3044
3045    #[test]
3046    fn unordered_bullet_styles_are_distinct() {
3047        assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
3048        assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
3049        assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
3050    }
3051
3052    #[test]
3053    fn ordered_delimiters() {
3054        let period = blocks("1. a");
3055        assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
3056        assert_eq!(period[0].list_prefix, "");
3057        assert_eq!(period[0].list_suffix, ".");
3058
3059        let paren = blocks("1) a");
3060        assert_eq!(paren[0].list_suffix, ")");
3061        assert_eq!(paren[0].list_prefix, "");
3062
3063        let paren_paren = blocks("(1) a");
3064        assert_eq!(paren_paren[0].list_prefix, "(");
3065        assert_eq!(paren_paren[0].list_suffix, ")");
3066    }
3067
3068    #[test]
3069    fn task_list_markers() {
3070        let b = blocks("- [ ] a\n- [x] b");
3071        assert_eq!(b.len(), 2);
3072        assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
3073        assert_eq!(b[1].marker, Some(MarkerType::Checked));
3074    }
3075
3076    #[test]
3077    fn code_block_with_language() {
3078        let b = blocks("```rust\nfn main() {}\n```");
3079        assert_eq!(b.len(), 1);
3080        assert!(b[0].is_code_block);
3081        assert_eq!(b[0].code_language.as_deref(), Some("rust"));
3082        let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
3083        assert_eq!(text, "fn main() {}");
3084    }
3085
3086    #[test]
3087    fn link_href() {
3088        let b = blocks("[text](http://example.com)");
3089        let s = first_span_with(&b[0], |s| s.text == "text");
3090        assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
3091    }
3092
3093    #[test]
3094    fn superscript_subscript() {
3095        assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
3096        assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
3097    }
3098
3099    #[test]
3100    fn delete_insert_verbatim() {
3101        assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
3102        assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
3103        assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
3104    }
3105
3106    #[test]
3107    fn blockquote_depth() {
3108        let els = parse_djot("> quoted", &DjotImportOptions::default());
3109        match &els[0] {
3110            ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
3111            _ => panic!("expected block"),
3112        }
3113    }
3114
3115    #[test]
3116    fn nested_list_indent() {
3117        // Djot nests a sub-list only when a blank line separates it from the
3118        // parent item and it is indented to the parent's content column
3119        // (2 spaces per level). Without the blank line the markers fold into
3120        // the paragraph as lazy continuation.
3121        let b = blocks("- a\n\n  - b\n\n    - c");
3122        assert_eq!(b.len(), 3);
3123        assert_eq!(b[0].list_indent, 0);
3124        assert_eq!(b[1].list_indent, 1);
3125        assert_eq!(b[2].list_indent, 2);
3126    }
3127
3128    #[test]
3129    fn table_parsed_as_table() {
3130        let els = parse_djot(
3131            "| a | b |\n|---|---|\n| c | d |",
3132            &DjotImportOptions::default(),
3133        );
3134        assert_eq!(els.len(), 1);
3135        match &els[0] {
3136            ParsedElement::Table(t) => {
3137                assert_eq!(t.header_rows, 1);
3138                assert_eq!(t.rows.len(), 2);
3139                assert_eq!(t.rows[0][0].spans[0].text, "a");
3140                assert_eq!(t.rows[1][1].spans[0].text, "d");
3141            }
3142            _ => panic!("expected table"),
3143        }
3144    }
3145
3146    #[test]
3147    fn smart_punctuation_normalised_to_unicode() {
3148        let text: String = blocks("a... b---c")[0]
3149            .spans
3150            .iter()
3151            .map(|s| s.text.as_str())
3152            .collect();
3153        assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
3154        assert!(text.contains('\u{2014}'), "em dash: {text:?}");
3155    }
3156
3157    #[test]
3158    fn unrepresentable_constructs_dropped_without_leaking_text() {
3159        // Thematic break between two paragraphs: no extra block, no stray text.
3160        let b = blocks("para1\n\n---\n\npara2");
3161        assert_eq!(b.len(), 2);
3162        assert_eq!(
3163            b[0].spans
3164                .iter()
3165                .map(|s| s.text.as_str())
3166                .collect::<String>(),
3167            "para1"
3168        );
3169        assert_eq!(
3170            b[1].spans
3171                .iter()
3172                .map(|s| s.text.as_str())
3173                .collect::<String>(),
3174            "para2"
3175        );
3176
3177        // Fenced div is unwrapped: its content survives, the fence does not.
3178        let d = blocks(":::\ninside\n:::");
3179        let joined: String = d
3180            .iter()
3181            .flat_map(|b| b.spans.iter())
3182            .map(|s| s.text.as_str())
3183            .collect();
3184        assert_eq!(joined, "inside");
3185
3186        // Inline math content is dropped, surrounding text kept.
3187        let m = blocks("before $`E=mc^2` after");
3188        let joined: String = m
3189            .iter()
3190            .flat_map(|b| b.spans.iter())
3191            .map(|s| s.text.as_str())
3192            .collect();
3193        assert!(joined.contains("before"), "{joined:?}");
3194        assert!(joined.contains("after"), "{joined:?}");
3195        assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
3196    }
3197
3198    #[test]
3199    fn empty_document_yields_one_empty_block() {
3200        let b = blocks("");
3201        assert_eq!(b.len(), 1);
3202        assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
3203    }
3204
3205    #[test]
3206    fn block_attributes_parse_into_block() {
3207        let b = blocks(
3208            "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
3209        );
3210        assert_eq!(b.len(), 1);
3211        assert_eq!(b[0].alignment, Some(Alignment::Center));
3212        assert_eq!(b[0].line_height, Some(1500));
3213        assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
3214        assert_eq!(b[0].non_breakable_lines, Some(true));
3215        assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
3216    }
3217
3218    #[test]
3219    fn spacing_block_attributes_parse_into_block() {
3220        // `top_margin` / `text_indent` let one block override the document-wide
3221        // paragraph spacing and first-line indent — what a scene break needs for
3222        // the paragraph that follows it.
3223        let b = blocks("{top_margin=24 text_indent=0}\nhello");
3224        assert_eq!(b.len(), 1);
3225        assert_eq!(b[0].top_margin, Some(24));
3226        assert_eq!(b[0].text_indent, Some(0));
3227    }
3228
3229    #[test]
3230    fn a_zero_text_indent_is_distinct_from_an_absent_one() {
3231        // The whole point of the attribute: `Some(0)` means "explicitly no
3232        // indent", which must not collapse to `None` ("use the document
3233        // default") — otherwise a scene break could not suppress the indent.
3234        let explicit = blocks("{text_indent=0}\nhello");
3235        let absent = blocks("hello");
3236        assert_eq!(explicit[0].text_indent, Some(0));
3237        assert_eq!(absent[0].text_indent, None);
3238        assert!(!explicit[0].is_inline_only());
3239        assert!(absent[0].is_inline_only());
3240    }
3241
3242    #[test]
3243    fn spacing_block_attributes_respect_import_options() {
3244        let src = "{top_margin=24 text_indent=0}\nhello";
3245        let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3246        assert_eq!(b[0].top_margin, None);
3247        assert_eq!(b[0].text_indent, None);
3248    }
3249
3250    #[test]
3251    fn block_attributes_on_heading() {
3252        let b = blocks("{alignment=right}\n# Title");
3253        assert_eq!(b[0].heading_level, Some(1));
3254        assert_eq!(b[0].alignment, Some(Alignment::Right));
3255    }
3256
3257    #[test]
3258    fn block_attributes_respect_import_options() {
3259        // With every optional attribute disabled, the `{…}` block attributes are
3260        // parsed and discarded — only the core paragraph survives.
3261        let src = "{alignment=center line_height=1500}\nhello";
3262        let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
3263        assert_eq!(b[0].alignment, None);
3264        assert_eq!(b[0].line_height, None);
3265        assert_eq!(
3266            b[0].spans
3267                .iter()
3268                .map(|s| s.text.as_str())
3269                .collect::<String>(),
3270            "hello"
3271        );
3272    }
3273
3274    #[test]
3275    fn list_item_block_attributes_are_dropped() {
3276        // Block attributes only bind to standalone paragraphs/headings; a list
3277        // item normalises them away (symmetric with the exporter).
3278        let b = blocks("{alignment=center}\n- item");
3279        assert!(b.iter().all(|blk| blk.alignment.is_none()));
3280    }
3281
3282    #[test]
3283    fn unknown_alignment_value_is_ignored() {
3284        let b = blocks("{alignment=sideways}\nhello");
3285        assert_eq!(b[0].alignment, None);
3286    }
3287}
3288
3289// ─── Cheap plain-text extraction ─────────────────────────────────────
3290
3291/// The `U+FFFC OBJECT REPLACEMENT CHARACTER` that stands for a table in the document's
3292/// text.
3293///
3294/// A table is not prose, but it *occupies a position* in the flow: the import mirrors this
3295/// single sentinel into the rope where the table sits
3296/// (`rope_helpers::rope_append_table_anchor`), then the cells as ordinary blocks. Anything
3297/// reconstructing the text a search runs against has to reproduce it, or every offset after
3298/// the first table is short by the two characters (the sentinel and its separator) that the
3299/// document really holds there.
3300pub const TABLE_ANCHOR: &str = "\u{FFFC}";
3301
3302/// One span's contribution to the addressable text.
3303///
3304/// An inline object carries no prose but **does** occupy one `U+FFFC` in the
3305/// document (`format_runs_from_spans` mirrors it there), so it has to occupy one
3306/// here too. Leaving it out makes this string shorter than the document it
3307/// claims to be byte-identical to, and every offset past the object — every
3308/// search hit, every comment anchor — lands a character early.
3309fn span_prose(span: &ParsedSpan, out: &mut String) {
3310    if span.image.is_some() || span.footnote_ref.is_some() {
3311        out.push('\u{FFFC}');
3312        return;
3313    }
3314    out.push_str(&span.text);
3315}
3316
3317fn block_prose(block: &ParsedBlock) -> String {
3318    let mut prose = String::new();
3319    for span in &block.spans {
3320        span_prose(span, &mut prose);
3321    }
3322    prose
3323}
3324
3325fn cell_prose(cell: &ParsedTableCell) -> String {
3326    let mut prose = String::new();
3327    for span in &cell.spans {
3328        span_prose(span, &mut prose);
3329    }
3330    prose
3331}
3332
3333/// The prose of a Djot document, with no entities, no store, and no threads.
3334///
3335/// [`parse_djot`] and [`ParsedElement::flatten_to_blocks`] were both already `pub`;
3336/// nothing chained them. This does, and that is the whole trick: it stops at the
3337/// *parse*, where a full import goes on to create a `Block` entity per paragraph, list
3338/// item and table cell, mirror each into the rope, and write its format runs.
3339///
3340/// # Why a project-wide search needs this
3341///
3342/// A host app searching a manuscript must ask "does this scene contain that word" of
3343/// **thousands** of Djot rows, on every keystroke. Doing that by importing each one into
3344/// a document is not a slow feature, it is a frozen app.
3345///
3346/// And searching the Djot *source* instead — the tempting shortcut — is simply wrong:
3347/// the source is markup. `http` matches inside a link's URL, `*` matches an emphasis
3348/// marker, and an occurrence count taken from the source does not agree with what a
3349/// replace re-derives inside the parsed document. Where a replace guards itself with
3350/// "the text moved under me, skip this field", a count taken from markup makes that
3351/// guard fire on perfectly good rows.
3352///
3353/// # The contract
3354///
3355/// The result is **byte-identical to the text the document searches** for the same Djot:
3356/// each block's spans concatenated, blocks joined by a single `\n`, and a table announced
3357/// by its [`TABLE_ANCHOR`] sentinel — exactly the string the import mirrors into the rope
3358/// and that `build_full_text_via_store` recomposes. So an offset found here is an offset
3359/// the document agrees with. On a live document the same string is served by
3360/// `TextDocument::to_addressable_text()`, which reads it off the search plumbing itself.
3361///
3362/// A property in `djot_roundtrip_tests` pins that across the whole generated feature set;
3363/// without it this would be a second, silently-diverging definition of "the text".
3364///
3365/// **Known exception: a footnote *definition*'s body.** This function treats it as out of
3366/// flow and omits it — matching `character_count()`, which does not count it — but the
3367/// live document mirrors its blocks into the rope, so today an in-document search DOES
3368/// run over the note's body and `to_addressable_text()` includes it. On a document with
3369/// footnote definitions the two strings differ by exactly those bodies; which side is
3370/// wrong is an open product question (should search see notes?), pinned as current
3371/// behaviour in `addressable_text_tests::footnote_bodies_are_searched_in_the_live_document`.
3372///
3373/// ⚠ It is **not** the same as `TextDocument::to_plain_text()`. That is the human-readable
3374/// *export* — same prose, same order, but with every object anchor omitted, so its offsets
3375/// drift by two characters per preceding table. The authority is what a search sees,
3376/// because that is what a replace edits.
3377/// (`to_plain_text` once also *ordered* a blockquote's prose differently; that was a bug,
3378/// fixed and pinned by `plain_text_order_tests` — see
3379/// `claude_reviews/text-document-plain-text-ordering.md`. The anchors are now the two
3380/// views' only difference.)
3381pub fn djot_to_plain_text(djot: &str, options: &DjotImportOptions) -> String {
3382    // Deliberately NOT `ParsedElement::flatten_to_blocks`: that helper drops a table's
3383    // anchor and yields only its cells, which would silently shift every offset in a
3384    // document containing a table by the two characters the document actually holds there.
3385    let elements = parse_djot(djot, options);
3386
3387    // Sized from the source: the prose is always shorter than the markup that carries it,
3388    // so this allocates once and never grows.
3389    let mut out = String::with_capacity(djot.len());
3390
3391    // The separator is decided by "is this the first block", NOT by "is the output still
3392    // empty". An EMPTY block (an empty code fence, say) is still a block: the document
3393    // holds an empty line for it, and an emptiness test would swallow both the block and
3394    // its separator, shifting every offset after it by one.
3395    let mut first = true;
3396    let push = |text: &str, out: &mut String, first: &mut bool| {
3397        if *first {
3398            *first = false;
3399        } else {
3400            out.push('\n');
3401        }
3402        out.push_str(text);
3403    };
3404
3405    for element in &elements {
3406        match element {
3407            ParsedElement::Block(block) => {
3408                push(&block_prose(block), &mut out, &mut first);
3409            }
3410            // A note's body is **out of flow**: it is not laid out where its
3411            // definition was written, it is not part of a copied fragment, and
3412            // the document does not count its characters. So it is not part of
3413            // the addressable text either — which is the one thing that has to
3414            // stay true here, since `character_count()` and this string are
3415            // compared directly.
3416            //
3417            // Its blocks do live in the rope, because they have to live
3418            // somewhere; they are simply not addressable prose.
3419            ParsedElement::FootnoteDefinition { .. } => {}
3420            ParsedElement::Table(table) => {
3421                // The import mirrors a table into the rope as a lone `U+FFFC` sentinel
3422                // followed by its cells, one per block (`rope_append_table_anchor`). The
3423                // sentinel occupies a real position in the text the document searches, so
3424                // it has to occupy one here too — otherwise every offset after a table is
3425                // short by two characters, and a snippet taken from this string would be
3426                // sliced in the wrong place.
3427                push(TABLE_ANCHOR, &mut out, &mut first);
3428                for row in &table.rows {
3429                    for cell in row {
3430                        push(&cell_prose(cell), &mut out, &mut first);
3431                    }
3432                }
3433            }
3434        }
3435    }
3436    out
3437}