Skip to main content

common/parser_tools/
content_parser.rs

1use crate::entities::{Alignment, ListStyle, MarkerType, TextDirection};
2use crate::parser_tools::djot_options::DjotImportOptions;
3
4/// A parsed inline span with formatting info
5#[derive(Debug, Clone, Default)]
6pub struct ParsedSpan {
7    pub text: String,
8    pub bold: bool,
9    pub italic: bool,
10    pub underline: bool,
11    pub strikeout: bool,
12    pub code: bool,
13    /// Superscript (djot `^x^`). Maps to `CharVerticalAlignment::SuperScript`.
14    pub superscript: bool,
15    /// Subscript (djot `~x~`). Maps to `CharVerticalAlignment::SubScript`.
16    pub subscript: bool,
17    pub link_href: Option<String>,
18}
19
20/// A parsed table cell containing inline spans.
21#[derive(Debug, Clone)]
22pub struct ParsedTableCell {
23    pub spans: Vec<ParsedSpan>,
24}
25
26/// A parsed table extracted from markdown or HTML.
27#[derive(Debug, Clone)]
28pub struct ParsedTable {
29    /// Number of header rows (typically 1 for markdown tables).
30    pub header_rows: usize,
31    /// All rows (header + body), each containing cells with their inline spans.
32    pub rows: Vec<Vec<ParsedTableCell>>,
33    /// Blockquote nesting depth at the point the table appeared
34    /// (0 = not inside a blockquote), mirroring `ParsedBlock::blockquote_depth`.
35    pub blockquote_depth: u32,
36}
37
38/// A parsed element: either a block or a table.
39#[derive(Debug, Clone)]
40pub enum ParsedElement {
41    Block(ParsedBlock),
42    Table(ParsedTable),
43}
44
45impl ParsedElement {
46    /// Extract blocks, flattening tables into one block per cell.
47    /// Use when table structure is not needed.
48    pub fn flatten_to_blocks(elements: Vec<ParsedElement>) -> Vec<ParsedBlock> {
49        let mut blocks = Vec::new();
50        for elem in elements {
51            match elem {
52                ParsedElement::Block(b) => blocks.push(b),
53                ParsedElement::Table(t) => {
54                    for row in t.rows {
55                        for cell in row {
56                            blocks.push(ParsedBlock {
57                                spans: cell.spans,
58                                heading_level: None,
59                                list_style: None,
60                                list_indent: 0,
61                                list_prefix: String::new(),
62                                list_suffix: String::new(),
63                                marker: None,
64                                is_code_block: false,
65                                code_language: None,
66                                blockquote_depth: t.blockquote_depth,
67                                line_height: None,
68                                non_breakable_lines: None,
69                                direction: None,
70                                background_color: None,
71                                alignment: None,
72                            });
73                        }
74                    }
75                }
76            }
77        }
78        if blocks.is_empty() {
79            blocks.push(ParsedBlock {
80                spans: vec![ParsedSpan {
81                    text: String::new(),
82                    ..Default::default()
83                }],
84                heading_level: None,
85                list_style: None,
86                list_indent: 0,
87                list_prefix: String::new(),
88                list_suffix: String::new(),
89                marker: None,
90                is_code_block: false,
91                code_language: None,
92                blockquote_depth: 0,
93                line_height: None,
94                non_breakable_lines: None,
95                direction: None,
96                background_color: None,
97                alignment: None,
98            });
99        }
100        blocks
101    }
102}
103
104/// A parsed block (paragraph, heading, list item, code block)
105#[derive(Debug, Clone)]
106pub struct ParsedBlock {
107    pub spans: Vec<ParsedSpan>,
108    pub heading_level: Option<i64>,
109    pub list_style: Option<ListStyle>,
110    pub list_indent: u32,
111    /// Ordered-list delimiter prefix (e.g. `"("` for djot `(1)` lists; empty
112    /// otherwise). Stored on the `List` entity for round-trip fidelity.
113    pub list_prefix: String,
114    /// Ordered-list delimiter suffix (`"."` for `1.`, `")"` for `1)`/`(1)`;
115    /// empty for unordered lists).
116    pub list_suffix: String,
117    /// Task-list checkbox marker (djot `- [ ]` / `- [x]`). Maps to
118    /// `Block.fmt_marker`. `None` for non-task blocks.
119    pub marker: Option<MarkerType>,
120    pub is_code_block: bool,
121    pub code_language: Option<String>,
122    pub blockquote_depth: u32,
123    pub line_height: Option<i64>,
124    pub non_breakable_lines: Option<bool>,
125    pub direction: Option<TextDirection>,
126    pub background_color: Option<String>,
127    /// Paragraph alignment (djot `{alignment=left|right|center|justify}`). Maps
128    /// to `Block.fmt_alignment`. `None` when no alignment attribute is present.
129    pub alignment: Option<Alignment>,
130}
131
132impl ParsedBlock {
133    /// Returns `true` when this block carries no block-level formatting,
134    /// meaning its content is purely inline.
135    pub fn is_inline_only(&self) -> bool {
136        self.heading_level.is_none()
137            && self.list_style.is_none()
138            && !self.is_code_block
139            && self.blockquote_depth == 0
140            && self.line_height.is_none()
141            && self.non_breakable_lines.is_none()
142            && self.direction.is_none()
143            && self.background_color.is_none()
144            && self.alignment.is_none()
145    }
146}
147
148// ─── Markdown parsing ────────────────────────────────────────────────
149
150pub fn parse_markdown(markdown: &str) -> Vec<ParsedElement> {
151    use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
152
153    let options =
154        Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES | Options::ENABLE_TASKLISTS;
155    let parser = Parser::new_ext(markdown, options);
156
157    let mut elements: Vec<ParsedElement> = Vec::new();
158    let mut current_spans: Vec<ParsedSpan> = Vec::new();
159    let mut current_heading: Option<i64> = None;
160    let mut current_list_style: Option<ListStyle> = None;
161    let mut is_code_block = false;
162    let mut code_language: Option<String> = None;
163    let mut blockquote_depth: u32 = 0;
164    let mut in_block = false;
165
166    // Formatting state stack
167    let mut bold = false;
168    let mut italic = false;
169    let mut strikeout = false;
170    let mut link_href: Option<String> = None;
171
172    // List style stack for nested lists (also tracks nesting depth)
173    let mut list_stack: Vec<Option<ListStyle>> = Vec::new();
174    let mut current_list_indent: u32 = 0;
175
176    // Table tracking state
177    let mut in_table = false;
178    let mut in_table_head = false;
179    let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
180    let mut current_row_cells: Vec<ParsedTableCell> = Vec::new();
181    let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
182    let mut table_header_rows: usize = 0;
183
184    for event in parser {
185        match event {
186            Event::Start(Tag::Paragraph) => {
187                in_block = true;
188                current_heading = None;
189                is_code_block = false;
190            }
191            Event::End(TagEnd::Paragraph) => {
192                if !current_spans.is_empty() || in_block {
193                    elements.push(ParsedElement::Block(ParsedBlock {
194                        spans: std::mem::take(&mut current_spans),
195                        heading_level: current_heading.take(),
196                        list_style: current_list_style.clone(),
197                        list_indent: current_list_indent,
198                        list_prefix: String::new(),
199                        list_suffix: String::new(),
200                        marker: None,
201                        is_code_block: false,
202                        code_language: None,
203                        blockquote_depth,
204                        line_height: None,
205                        non_breakable_lines: None,
206                        direction: None,
207                        background_color: None,
208                        alignment: None,
209                    }));
210                }
211                in_block = false;
212                current_list_style = None;
213            }
214            Event::Start(Tag::Heading { level, .. }) => {
215                in_block = true;
216                current_heading = Some(heading_level_to_i64(level));
217                is_code_block = false;
218            }
219            Event::End(TagEnd::Heading(_)) => {
220                elements.push(ParsedElement::Block(ParsedBlock {
221                    spans: std::mem::take(&mut current_spans),
222                    heading_level: current_heading.take(),
223                    list_style: None,
224                    list_indent: 0,
225                    list_prefix: String::new(),
226                    list_suffix: String::new(),
227                    marker: None,
228                    is_code_block: false,
229                    code_language: None,
230                    blockquote_depth,
231                    line_height: None,
232                    non_breakable_lines: None,
233                    direction: None,
234                    background_color: None,
235                    alignment: None,
236                }));
237                in_block = false;
238            }
239            Event::Start(Tag::List(ordered)) => {
240                let style = if ordered.is_some() {
241                    Some(ListStyle::Decimal)
242                } else {
243                    Some(ListStyle::Disc)
244                };
245                list_stack.push(style);
246            }
247            Event::End(TagEnd::List(_)) => {
248                list_stack.pop();
249            }
250            Event::Start(Tag::Item) => {
251                // Flush any accumulated spans from the parent item before
252                // starting a child item in a tight list
253                if !current_spans.is_empty() {
254                    elements.push(ParsedElement::Block(ParsedBlock {
255                        spans: std::mem::take(&mut current_spans),
256                        heading_level: None,
257                        list_style: current_list_style.clone(),
258                        list_indent: current_list_indent,
259                        list_prefix: String::new(),
260                        list_suffix: String::new(),
261                        marker: None,
262                        is_code_block: false,
263                        code_language: None,
264                        blockquote_depth,
265                        line_height: None,
266                        non_breakable_lines: None,
267                        direction: None,
268                        background_color: None,
269                        alignment: None,
270                    }));
271                }
272                in_block = true;
273                current_list_style = list_stack.last().cloned().flatten();
274                current_list_indent = if list_stack.is_empty() {
275                    0
276                } else {
277                    (list_stack.len() - 1) as u32
278                };
279            }
280            Event::End(TagEnd::Item) => {
281                // The paragraph inside the item will have already been flushed,
282                // but if there was no inner paragraph (tight list), flush now.
283                if !current_spans.is_empty() {
284                    elements.push(ParsedElement::Block(ParsedBlock {
285                        spans: std::mem::take(&mut current_spans),
286                        heading_level: None,
287                        list_style: current_list_style.clone(),
288                        list_indent: current_list_indent,
289                        list_prefix: String::new(),
290                        list_suffix: String::new(),
291                        marker: None,
292                        is_code_block: false,
293                        code_language: None,
294                        blockquote_depth,
295                        line_height: None,
296                        non_breakable_lines: None,
297                        direction: None,
298                        background_color: None,
299                        alignment: None,
300                    }));
301                }
302                in_block = false;
303                current_list_style = None;
304            }
305            Event::Start(Tag::CodeBlock(kind)) => {
306                in_block = true;
307                is_code_block = true;
308                code_language = match &kind {
309                    pulldown_cmark::CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
310                        Some(lang.to_string())
311                    }
312                    _ => None,
313                };
314            }
315            Event::End(TagEnd::CodeBlock) => {
316                // pulldown-cmark appends a trailing '\n' to code block text — strip it
317                if let Some(last) = current_spans.last_mut()
318                    && last.text.ends_with('\n')
319                {
320                    last.text.truncate(last.text.len() - 1);
321                }
322                elements.push(ParsedElement::Block(ParsedBlock {
323                    spans: std::mem::take(&mut current_spans),
324                    heading_level: None,
325                    list_style: None,
326                    list_indent: 0,
327                    list_prefix: String::new(),
328                    list_suffix: String::new(),
329                    marker: None,
330                    is_code_block: true,
331                    code_language: code_language.take(),
332                    blockquote_depth,
333                    line_height: None,
334                    non_breakable_lines: None,
335                    direction: None,
336                    background_color: None,
337                    alignment: None,
338                }));
339                in_block = false;
340                is_code_block = false;
341            }
342            // ─── Table events ───────────────────────────────────────
343            Event::Start(Tag::Table(_)) => {
344                in_table = true;
345                in_table_head = false;
346                table_rows.clear();
347                current_row_cells.clear();
348                current_cell_spans.clear();
349                table_header_rows = 0;
350            }
351            Event::End(TagEnd::Table) => {
352                elements.push(ParsedElement::Table(ParsedTable {
353                    header_rows: table_header_rows,
354                    rows: std::mem::take(&mut table_rows),
355                    blockquote_depth,
356                }));
357                in_table = false;
358            }
359            Event::Start(Tag::TableHead) => {
360                in_table_head = true;
361                current_row_cells.clear();
362            }
363            Event::End(TagEnd::TableHead) => {
364                // Flush the header row
365                table_rows.push(std::mem::take(&mut current_row_cells));
366                table_header_rows += 1;
367                in_table_head = false;
368            }
369            Event::Start(Tag::TableRow) => {
370                current_row_cells.clear();
371            }
372            Event::End(TagEnd::TableRow) if !in_table_head => {
373                // Body rows only — header row is flushed in End(TableHead)
374                table_rows.push(std::mem::take(&mut current_row_cells));
375            }
376            Event::Start(Tag::TableCell) => {
377                current_cell_spans.clear();
378            }
379            Event::End(TagEnd::TableCell) => {
380                current_row_cells.push(ParsedTableCell {
381                    spans: std::mem::take(&mut current_cell_spans),
382                });
383            }
384            // ─── Inline formatting ──────────────────────────────────
385            Event::Start(Tag::Emphasis) => {
386                italic = true;
387            }
388            Event::End(TagEnd::Emphasis) => {
389                italic = false;
390            }
391            Event::Start(Tag::Strong) => {
392                bold = true;
393            }
394            Event::End(TagEnd::Strong) => {
395                bold = false;
396            }
397            Event::Start(Tag::Strikethrough) => {
398                strikeout = true;
399            }
400            Event::End(TagEnd::Strikethrough) => {
401                strikeout = false;
402            }
403            Event::Start(Tag::Link { dest_url, .. }) => {
404                link_href = Some(dest_url.to_string());
405            }
406            Event::End(TagEnd::Link) => {
407                link_href = None;
408            }
409            Event::Text(text) => {
410                let span = ParsedSpan {
411                    text: text.to_string(),
412                    bold,
413                    italic,
414                    underline: false,
415                    strikeout,
416                    code: is_code_block,
417                    superscript: false,
418                    subscript: false,
419                    link_href: link_href.clone(),
420                };
421                if in_table {
422                    current_cell_spans.push(span);
423                } else {
424                    if !in_block {
425                        in_block = true;
426                    }
427                    current_spans.push(span);
428                }
429            }
430            Event::Code(text) => {
431                let span = ParsedSpan {
432                    text: text.to_string(),
433                    bold,
434                    italic,
435                    underline: false,
436                    strikeout,
437                    code: true,
438                    superscript: false,
439                    subscript: false,
440                    link_href: link_href.clone(),
441                };
442                if in_table {
443                    current_cell_spans.push(span);
444                } else {
445                    if !in_block {
446                        in_block = true;
447                    }
448                    current_spans.push(span);
449                }
450            }
451            Event::SoftBreak => {
452                let span = ParsedSpan {
453                    text: " ".to_string(),
454                    bold,
455                    italic,
456                    underline: false,
457                    strikeout,
458                    code: false,
459                    superscript: false,
460                    subscript: false,
461                    link_href: link_href.clone(),
462                };
463                if in_table {
464                    current_cell_spans.push(span);
465                } else {
466                    current_spans.push(span);
467                }
468            }
469            Event::HardBreak if !current_spans.is_empty() || in_block => {
470                // Finalize current block
471                elements.push(ParsedElement::Block(ParsedBlock {
472                    spans: std::mem::take(&mut current_spans),
473                    heading_level: current_heading.take(),
474                    list_style: current_list_style.clone(),
475                    list_indent: current_list_indent,
476                    list_prefix: String::new(),
477                    list_suffix: String::new(),
478                    marker: None,
479                    is_code_block,
480                    code_language: code_language.clone(),
481                    blockquote_depth,
482                    line_height: None,
483                    non_breakable_lines: None,
484                    direction: None,
485                    background_color: None,
486                    alignment: None,
487                }));
488            }
489            Event::Start(Tag::BlockQuote(_)) => {
490                blockquote_depth += 1;
491            }
492            Event::End(TagEnd::BlockQuote(_)) => {
493                blockquote_depth = blockquote_depth.saturating_sub(1);
494            }
495            _ => {}
496        }
497    }
498
499    // Flush any remaining content
500    if !current_spans.is_empty() {
501        elements.push(ParsedElement::Block(ParsedBlock {
502            spans: std::mem::take(&mut current_spans),
503            heading_level: current_heading,
504            list_style: current_list_style,
505            list_indent: current_list_indent,
506            list_prefix: String::new(),
507            list_suffix: String::new(),
508            marker: None,
509            is_code_block,
510            code_language: code_language.take(),
511            blockquote_depth,
512            line_height: None,
513            non_breakable_lines: None,
514            direction: None,
515            background_color: None,
516            alignment: None,
517        }));
518    }
519
520    // If no elements were parsed, create a single empty paragraph
521    if elements.is_empty() {
522        elements.push(ParsedElement::Block(ParsedBlock {
523            spans: vec![ParsedSpan {
524                text: String::new(),
525                ..Default::default()
526            }],
527            heading_level: None,
528            list_style: None,
529            list_indent: 0,
530            list_prefix: String::new(),
531            list_suffix: String::new(),
532            marker: None,
533            is_code_block: false,
534            code_language: None,
535            blockquote_depth: 0,
536            line_height: None,
537            non_breakable_lines: None,
538            direction: None,
539            background_color: None,
540            alignment: None,
541        }));
542    }
543
544    elements
545}
546
547fn heading_level_to_i64(level: pulldown_cmark::HeadingLevel) -> i64 {
548    use pulldown_cmark::HeadingLevel;
549    match level {
550        HeadingLevel::H1 => 1,
551        HeadingLevel::H2 => 2,
552        HeadingLevel::H3 => 3,
553        HeadingLevel::H4 => 4,
554        HeadingLevel::H5 => 5,
555        HeadingLevel::H6 => 6,
556    }
557}
558
559// ─── HTML parsing ────────────────────────────────────────────────────
560
561use scraper::Node;
562
563/// Parsed CSS block-level styles from an inline `style` attribute.
564#[derive(Debug, Clone, Default)]
565struct BlockStyles {
566    line_height: Option<i64>,
567    non_breakable_lines: Option<bool>,
568    direction: Option<TextDirection>,
569    background_color: Option<String>,
570}
571
572/// Parse relevant CSS properties from an inline style string.
573/// Handles: line-height, white-space, direction, background-color.
574fn parse_block_styles(style: &str) -> BlockStyles {
575    let mut result = BlockStyles::default();
576    for part in style.split(';') {
577        let part = part.trim();
578        if let Some((prop, val)) = part.split_once(':') {
579            let prop = prop.trim().to_ascii_lowercase();
580            let val = val.trim();
581            match prop.as_str() {
582                "line-height" => {
583                    // Try parsing as a plain number (multiplier)
584                    if let Ok(v) = val.parse::<f64>() {
585                        result.line_height = Some((v * 1000.0) as i64);
586                    }
587                }
588                "white-space" if val == "pre" || val == "nowrap" || val == "pre-wrap" => {
589                    result.non_breakable_lines = Some(true);
590                }
591                "direction" => {
592                    if val.eq_ignore_ascii_case("rtl") {
593                        result.direction = Some(TextDirection::RightToLeft);
594                    } else if val.eq_ignore_ascii_case("ltr") {
595                        result.direction = Some(TextDirection::LeftToRight);
596                    }
597                }
598                "background-color" | "background" => {
599                    result.background_color = Some(val.to_string());
600                }
601                _ => {}
602            }
603        }
604    }
605    result
606}
607
608pub fn parse_html(html: &str) -> Vec<ParsedBlock> {
609    ParsedElement::flatten_to_blocks(parse_html_elements(html))
610}
611
612pub fn parse_html_elements(html: &str) -> Vec<ParsedElement> {
613    use scraper::Html;
614
615    let fragment = Html::parse_fragment(html);
616    let mut elements: Vec<ParsedElement> = Vec::new();
617
618    // Walk the DOM tree starting from the root
619    let root = fragment.root_element();
620
621    #[derive(Clone, Default)]
622    struct FmtState {
623        bold: bool,
624        italic: bool,
625        underline: bool,
626        strikeout: bool,
627        code: bool,
628        link_href: Option<String>,
629    }
630
631    const MAX_RECURSION_DEPTH: usize = 256;
632
633    /// Collect inline spans from a `<td>` or `<th>` cell element.
634    fn collect_cell_spans(
635        node: ego_tree::NodeRef<Node>,
636        state: &FmtState,
637        spans: &mut Vec<ParsedSpan>,
638        depth: usize,
639    ) {
640        if depth > MAX_RECURSION_DEPTH {
641            return;
642        }
643        for child in node.children() {
644            match child.value() {
645                Node::Text(text) => {
646                    let t = text.text.to_string();
647                    if !t.is_empty() {
648                        spans.push(ParsedSpan {
649                            text: t,
650                            bold: state.bold,
651                            italic: state.italic,
652                            underline: state.underline,
653                            strikeout: state.strikeout,
654                            code: state.code,
655                            superscript: false,
656                            subscript: false,
657                            link_href: state.link_href.clone(),
658                        });
659                    }
660                }
661                Node::Element(el) => {
662                    let tag = el.name();
663                    let mut new_state = state.clone();
664                    match tag {
665                        "b" | "strong" => new_state.bold = true,
666                        "i" | "em" => new_state.italic = true,
667                        "u" | "ins" => new_state.underline = true,
668                        "s" | "del" | "strike" => new_state.strikeout = true,
669                        "code" => new_state.code = true,
670                        "a" => {
671                            if let Some(href) = el.attr("href") {
672                                new_state.link_href = Some(href.to_string());
673                            }
674                        }
675                        _ => {}
676                    }
677                    collect_cell_spans(child, &new_state, spans, depth + 1);
678                }
679                _ => {}
680            }
681        }
682    }
683
684    /// Parse a `<table>` element into a ParsedTable.
685    fn parse_table_element(table_node: ego_tree::NodeRef<Node>) -> ParsedTable {
686        let mut rows: Vec<Vec<ParsedTableCell>> = Vec::new();
687        let mut header_rows: usize = 0;
688
689        fn collect_rows(
690            node: ego_tree::NodeRef<Node>,
691            rows: &mut Vec<Vec<ParsedTableCell>>,
692            header_rows: &mut usize,
693            in_thead: bool,
694        ) {
695            for child in node.children() {
696                if let Node::Element(el) = child.value() {
697                    match el.name() {
698                        "thead" => collect_rows(child, rows, header_rows, true),
699                        "tbody" | "tfoot" => collect_rows(child, rows, header_rows, false),
700                        "tr" => {
701                            let mut cells: Vec<ParsedTableCell> = Vec::new();
702                            for td in child.children() {
703                                if let Node::Element(td_el) = td.value()
704                                    && matches!(td_el.name(), "td" | "th")
705                                {
706                                    let mut spans = Vec::new();
707                                    let state = FmtState::default();
708                                    collect_cell_spans(td, &state, &mut spans, 0);
709                                    if spans.is_empty() {
710                                        spans.push(ParsedSpan::default());
711                                    }
712                                    cells.push(ParsedTableCell { spans });
713                                }
714                            }
715                            if !cells.is_empty() {
716                                rows.push(cells);
717                                if in_thead {
718                                    *header_rows += 1;
719                                }
720                            }
721                        }
722                        _ => {}
723                    }
724                }
725            }
726        }
727
728        collect_rows(table_node, &mut rows, &mut header_rows, false);
729
730        // Tables without explicit <thead> but with <th> cells: treat first row as header
731        if header_rows == 0 && !rows.is_empty() {
732            header_rows = 1;
733        }
734
735        ParsedTable {
736            header_rows,
737            rows,
738            // The caller (`walk_node`) sets the real depth — this helper has
739            // no visibility into the surrounding blockquote nesting.
740            blockquote_depth: 0,
741        }
742    }
743
744    fn walk_node(
745        node: ego_tree::NodeRef<Node>,
746        state: &FmtState,
747        elements: &mut Vec<ParsedElement>,
748        current_list_style: &Option<ListStyle>,
749        blockquote_depth: u32,
750        list_depth: u32,
751        depth: usize,
752    ) {
753        if depth > MAX_RECURSION_DEPTH {
754            return;
755        }
756        match node.value() {
757            Node::Element(el) => {
758                let tag = el.name();
759                let mut new_state = state.clone();
760                let mut new_list_style = current_list_style.clone();
761                let mut bq_depth = blockquote_depth;
762                let mut new_list_depth = list_depth;
763
764                // Determine if this is a block-level element
765                let is_block_tag = matches!(
766                    tag,
767                    "p" | "div"
768                        | "h1"
769                        | "h2"
770                        | "h3"
771                        | "h4"
772                        | "h5"
773                        | "h6"
774                        | "li"
775                        | "pre"
776                        | "br"
777                        | "blockquote"
778                        | "body"
779                        | "html"
780                );
781
782                // Update formatting state
783                match tag {
784                    "b" | "strong" => new_state.bold = true,
785                    "i" | "em" => new_state.italic = true,
786                    "u" | "ins" => new_state.underline = true,
787                    "s" | "del" | "strike" => new_state.strikeout = true,
788                    "code" => new_state.code = true,
789                    "a" => {
790                        if let Some(href) = el.attr("href") {
791                            new_state.link_href = Some(href.to_string());
792                        }
793                    }
794                    "ul" => {
795                        new_list_style = Some(ListStyle::Disc);
796                        new_list_depth = list_depth + 1;
797                    }
798                    "ol" => {
799                        new_list_style = Some(ListStyle::Decimal);
800                        new_list_depth = list_depth + 1;
801                    }
802                    "blockquote" => {
803                        bq_depth += 1;
804                    }
805                    _ => {}
806                }
807
808                // Determine heading level
809                let heading_level = match tag {
810                    "h1" => Some(1),
811                    "h2" => Some(2),
812                    "h3" => Some(3),
813                    "h4" => Some(4),
814                    "h5" => Some(5),
815                    "h6" => Some(6),
816                    _ => None,
817                };
818
819                let is_code_block = tag == "pre";
820
821                // Extract code language from <pre><code class="language-xxx">
822                let code_language = if is_code_block {
823                    node.children().find_map(|child| {
824                        if let Node::Element(cel) = child.value()
825                            && cel.name() == "code"
826                            && let Some(cls) = cel.attr("class")
827                        {
828                            return cls
829                                .split_whitespace()
830                                .find_map(|c| c.strip_prefix("language-"))
831                                .map(|l| l.to_string());
832                        }
833                        None
834                    })
835                } else {
836                    None
837                };
838
839                // Extract CSS styles from block-level elements
840                let css = if is_block_tag {
841                    el.attr("style").map(parse_block_styles).unwrap_or_default()
842                } else {
843                    BlockStyles::default()
844                };
845
846                if tag == "table" {
847                    // Parse table structure into a ParsedTable
848                    let mut parsed_table = parse_table_element(node);
849                    if !parsed_table.rows.is_empty() {
850                        parsed_table.blockquote_depth = bq_depth;
851                        elements.push(ParsedElement::Table(parsed_table));
852                    }
853                    return;
854                }
855
856                if tag == "br" {
857                    // <br> creates a new block
858                    elements.push(ParsedElement::Block(ParsedBlock {
859                        spans: vec![ParsedSpan {
860                            text: String::new(),
861                            ..Default::default()
862                        }],
863                        heading_level: None,
864                        list_style: None,
865                        list_indent: 0,
866                        list_prefix: String::new(),
867                        list_suffix: String::new(),
868                        marker: None,
869                        is_code_block: false,
870                        code_language: None,
871                        blockquote_depth: bq_depth,
872                        line_height: None,
873                        non_breakable_lines: None,
874                        direction: None,
875                        background_color: None,
876                        alignment: None,
877                    }));
878                    return;
879                }
880
881                if tag == "blockquote" {
882                    // Blockquote is a container — recurse into children with increased depth
883                    for child in node.children() {
884                        walk_node(
885                            child,
886                            &new_state,
887                            elements,
888                            &new_list_style,
889                            bq_depth,
890                            new_list_depth,
891                            depth + 1,
892                        );
893                    }
894                } else if is_block_tag && tag != "br" {
895                    // Start collecting spans for a new block.
896                    // Use a temporary buffer so that nested block-level
897                    // elements (e.g. sub-lists inside <li>) are collected
898                    // separately and appended *after* the parent block.
899                    let mut spans: Vec<ParsedSpan> = Vec::new();
900                    let mut nested_elements: Vec<ParsedElement> = Vec::new();
901                    collect_inline_spans(
902                        node,
903                        &new_state,
904                        &mut spans,
905                        &new_list_style,
906                        &mut nested_elements,
907                        bq_depth,
908                        new_list_depth,
909                        depth + 1,
910                    );
911
912                    let list_style_for_block = if tag == "li" {
913                        new_list_style.clone()
914                    } else {
915                        None
916                    };
917
918                    let list_indent_for_block = if tag == "li" {
919                        new_list_depth.saturating_sub(1)
920                    } else {
921                        0
922                    };
923
924                    if !spans.is_empty() || heading_level.is_some() {
925                        elements.push(ParsedElement::Block(ParsedBlock {
926                            spans,
927                            heading_level,
928                            list_style: list_style_for_block,
929                            list_indent: list_indent_for_block,
930                            list_prefix: String::new(),
931                            list_suffix: String::new(),
932                            marker: None,
933                            is_code_block,
934                            code_language,
935                            blockquote_depth: bq_depth,
936                            line_height: css.line_height,
937                            non_breakable_lines: css.non_breakable_lines,
938                            direction: css.direction,
939                            background_color: css.background_color,
940                            alignment: None,
941                        }));
942                    }
943                    // Append nested block elements after the parent block
944                    elements.append(&mut nested_elements);
945                } else if matches!(tag, "ul" | "ol" | "thead" | "tbody" | "tr") {
946                    // Container elements: recurse into children
947                    for child in node.children() {
948                        walk_node(
949                            child,
950                            &new_state,
951                            elements,
952                            &new_list_style,
953                            bq_depth,
954                            new_list_depth,
955                            depth + 1,
956                        );
957                    }
958                } else {
959                    // Inline element or unknown: recurse
960                    for child in node.children() {
961                        walk_node(
962                            child,
963                            &new_state,
964                            elements,
965                            current_list_style,
966                            bq_depth,
967                            list_depth,
968                            depth + 1,
969                        );
970                    }
971                }
972            }
973            Node::Text(text) => {
974                let t = text.text.to_string();
975                let trimmed = t.trim();
976                if !trimmed.is_empty() {
977                    // Bare text not in a block — create a paragraph
978                    elements.push(ParsedElement::Block(ParsedBlock {
979                        spans: vec![ParsedSpan {
980                            text: trimmed.to_string(),
981                            bold: state.bold,
982                            italic: state.italic,
983                            underline: state.underline,
984                            strikeout: state.strikeout,
985                            code: state.code,
986                            superscript: false,
987                            subscript: false,
988                            link_href: state.link_href.clone(),
989                        }],
990                        heading_level: None,
991                        list_style: None,
992                        list_indent: 0,
993                        list_prefix: String::new(),
994                        list_suffix: String::new(),
995                        marker: None,
996                        is_code_block: false,
997                        code_language: None,
998                        blockquote_depth,
999                        line_height: None,
1000                        non_breakable_lines: None,
1001                        direction: None,
1002                        background_color: None,
1003                        alignment: None,
1004                    }));
1005                }
1006            }
1007            _ => {
1008                // Document, Comment, etc. — recurse children
1009                for child in node.children() {
1010                    walk_node(
1011                        child,
1012                        state,
1013                        elements,
1014                        current_list_style,
1015                        blockquote_depth,
1016                        list_depth,
1017                        depth + 1,
1018                    );
1019                }
1020            }
1021        }
1022    }
1023
1024    /// Collect inline spans from a block-level element's children.
1025    /// If a nested block-level element is encountered, it is flushed as a
1026    /// separate block.
1027    #[allow(clippy::too_many_arguments)]
1028    fn collect_inline_spans(
1029        node: ego_tree::NodeRef<Node>,
1030        state: &FmtState,
1031        spans: &mut Vec<ParsedSpan>,
1032        current_list_style: &Option<ListStyle>,
1033        elements: &mut Vec<ParsedElement>,
1034        blockquote_depth: u32,
1035        list_depth: u32,
1036        depth: usize,
1037    ) {
1038        if depth > MAX_RECURSION_DEPTH {
1039            return;
1040        }
1041        for child in node.children() {
1042            match child.value() {
1043                Node::Text(text) => {
1044                    let t = text.text.to_string();
1045                    if !t.is_empty() {
1046                        spans.push(ParsedSpan {
1047                            text: t,
1048                            bold: state.bold,
1049                            italic: state.italic,
1050                            underline: state.underline,
1051                            strikeout: state.strikeout,
1052                            code: state.code,
1053                            superscript: false,
1054                            subscript: false,
1055                            link_href: state.link_href.clone(),
1056                        });
1057                    }
1058                }
1059                Node::Element(el) => {
1060                    let tag = el.name();
1061                    let mut new_state = state.clone();
1062
1063                    match tag {
1064                        "b" | "strong" => new_state.bold = true,
1065                        "i" | "em" => new_state.italic = true,
1066                        "u" | "ins" => new_state.underline = true,
1067                        "s" | "del" | "strike" => new_state.strikeout = true,
1068                        "code" => new_state.code = true,
1069                        "a" => {
1070                            if let Some(href) = el.attr("href") {
1071                                new_state.link_href = Some(href.to_string());
1072                            }
1073                        }
1074                        _ => {}
1075                    }
1076
1077                    // Check for nested block elements
1078                    let nested_block = matches!(
1079                        tag,
1080                        "p" | "div"
1081                            | "h1"
1082                            | "h2"
1083                            | "h3"
1084                            | "h4"
1085                            | "h5"
1086                            | "h6"
1087                            | "li"
1088                            | "pre"
1089                            | "blockquote"
1090                            | "ul"
1091                            | "ol"
1092                    );
1093
1094                    if tag == "br" {
1095                        // br within a block: treat as splitting into new block
1096                        // For simplicity, just add a newline to current span
1097                        spans.push(ParsedSpan {
1098                            text: String::new(),
1099                            ..Default::default()
1100                        });
1101                    } else if nested_block || tag == "table" {
1102                        // Flush as separate element
1103                        walk_node(
1104                            child,
1105                            &new_state,
1106                            elements,
1107                            current_list_style,
1108                            blockquote_depth,
1109                            list_depth,
1110                            depth + 1,
1111                        );
1112                    } else {
1113                        // Inline element: recurse
1114                        collect_inline_spans(
1115                            child,
1116                            &new_state,
1117                            spans,
1118                            current_list_style,
1119                            elements,
1120                            blockquote_depth,
1121                            list_depth,
1122                            depth + 1,
1123                        );
1124                    }
1125                }
1126                _ => {}
1127            }
1128        }
1129    }
1130
1131    let initial_state = FmtState::default();
1132    // Treat the root element as a block-level container so that
1133    // top-level inline elements (e.g. `<b>Bold</b> <em>Italic</em>`)
1134    // are grouped into a single block instead of becoming separate blocks.
1135    let mut root_spans: Vec<ParsedSpan> = Vec::new();
1136    collect_inline_spans(
1137        *root,
1138        &initial_state,
1139        &mut root_spans,
1140        &None,
1141        &mut elements,
1142        0,
1143        0,
1144        0,
1145    );
1146    if !root_spans.is_empty() {
1147        elements.push(ParsedElement::Block(ParsedBlock {
1148            spans: root_spans,
1149            heading_level: None,
1150            list_style: None,
1151            list_indent: 0,
1152            list_prefix: String::new(),
1153            list_suffix: String::new(),
1154            marker: None,
1155            is_code_block: false,
1156            code_language: None,
1157            blockquote_depth: 0,
1158            line_height: None,
1159            non_breakable_lines: None,
1160            direction: None,
1161            background_color: None,
1162            alignment: None,
1163        }));
1164    }
1165
1166    // If no elements were parsed, create a single empty paragraph
1167    if elements.is_empty() {
1168        elements.push(ParsedElement::Block(ParsedBlock {
1169            spans: vec![ParsedSpan {
1170                text: String::new(),
1171                ..Default::default()
1172            }],
1173            heading_level: None,
1174            list_style: None,
1175            list_indent: 0,
1176            list_prefix: String::new(),
1177            list_suffix: String::new(),
1178            marker: None,
1179            is_code_block: false,
1180            code_language: None,
1181            blockquote_depth: 0,
1182            line_height: None,
1183            non_breakable_lines: None,
1184            direction: None,
1185            background_color: None,
1186            alignment: None,
1187        }));
1188    }
1189
1190    elements
1191}
1192
1193/// Convert a `ParsedSpan` (parser output) into the `CharacterFormat` used by
1194/// `FormatRun`. `is_code_block` forces `monospace` as the font family for
1195/// every span inside a code block.
1196pub fn character_format_from_span(
1197    span: &ParsedSpan,
1198    is_code_block: bool,
1199) -> crate::format_runs::CharacterFormat {
1200    use crate::entities::CharVerticalAlignment;
1201    crate::format_runs::CharacterFormat {
1202        font_bold: if span.bold { Some(true) } else { None },
1203        font_italic: if span.italic { Some(true) } else { None },
1204        font_underline: if span.underline { Some(true) } else { None },
1205        font_strikeout: if span.strikeout { Some(true) } else { None },
1206        font_family: if span.code || is_code_block {
1207            Some("monospace".to_string())
1208        } else {
1209            None
1210        },
1211        anchor_href: span.link_href.clone(),
1212        is_anchor: if span.link_href.is_some() {
1213            Some(true)
1214        } else {
1215            None
1216        },
1217        vertical_alignment: if span.superscript {
1218            Some(CharVerticalAlignment::SuperScript)
1219        } else if span.subscript {
1220            Some(CharVerticalAlignment::SubScript)
1221        } else {
1222            None
1223        },
1224        ..Default::default()
1225    }
1226}
1227
1228/// Translate a slice of parsed spans into `(plain_text, format_runs)`.
1229///
1230/// One non-default span yields one `FormatRun`; spans with empty
1231/// `CharacterFormat` (no decoration, no link, no code) emit no run, since an
1232/// absent run means "inherit default formatting" in the new model. Adjacent
1233/// runs with identical formats are coalesced via `coalesce_in_place` so the
1234/// resulting vector satisfies `debug_assert_well_formed`.
1235///
1236/// Returns the concatenated `plain_text` of all spans and a sorted,
1237/// non-overlapping, coalesced `Vec<FormatRun>`. Both safe to feed straight
1238/// into the store under the dual-write bridge.
1239pub fn format_runs_from_spans(
1240    spans: &[ParsedSpan],
1241    is_code_block: bool,
1242) -> (String, Vec<crate::format_runs::FormatRun>) {
1243    use crate::format_runs::{CharacterFormat, FormatRun, coalesce_in_place};
1244
1245    let mut plain_text = String::new();
1246    let mut runs: Vec<FormatRun> = Vec::new();
1247    let default = CharacterFormat::default();
1248
1249    for span in spans {
1250        let byte_start = plain_text.len() as u32;
1251        plain_text.push_str(&span.text);
1252        let byte_end = plain_text.len() as u32;
1253        if byte_start == byte_end {
1254            continue;
1255        }
1256        let format = character_format_from_span(span, is_code_block);
1257        if format == default {
1258            continue;
1259        }
1260        runs.push(FormatRun {
1261            byte_start,
1262            byte_end,
1263            format,
1264        });
1265    }
1266    coalesce_in_place(&mut runs);
1267    (plain_text, runs)
1268}
1269
1270// ─── Djot parsing ────────────────────────────────────────────────────
1271
1272/// Map a jotdown unordered/task bullet marker to a model `ListStyle`.
1273///
1274/// The mapping is a stable bijection (`-`↔Disc, `*`↔Circle, `+`↔Square) so the
1275/// djot exporter can recover the exact bullet character for a lossless
1276/// round-trip.
1277fn djot_bullet_style(b: jotdown::ListBulletType) -> ListStyle {
1278    use jotdown::ListBulletType as B;
1279    match b {
1280        B::Dash => ListStyle::Disc,
1281        B::Star => ListStyle::Circle,
1282        B::Plus => ListStyle::Square,
1283    }
1284}
1285
1286/// Map a jotdown ordered-list numbering scheme to a model `ListStyle`.
1287fn djot_ordered_style(n: jotdown::OrderedListNumbering) -> ListStyle {
1288    use jotdown::OrderedListNumbering as N;
1289    match n {
1290        N::Decimal => ListStyle::Decimal,
1291        N::AlphaLower => ListStyle::LowerAlpha,
1292        N::AlphaUpper => ListStyle::UpperAlpha,
1293        N::RomanLower => ListStyle::LowerRoman,
1294        N::RomanUpper => ListStyle::UpperRoman,
1295    }
1296}
1297
1298/// Map a jotdown ordered-list delimiter to the `(prefix, suffix)` affixes
1299/// stored on the `List` entity (`1.` → `("", ".")`, `1)` → `("", ")")`,
1300/// `(1)` → `("(", ")")`).
1301fn djot_ordered_affixes(style: jotdown::OrderedListStyle) -> (String, String) {
1302    use jotdown::OrderedListStyle as S;
1303    match style {
1304        S::Period => (String::new(), ".".to_string()),
1305        S::Paren => (String::new(), ")".to_string()),
1306        S::ParenParen => ("(".to_string(), ")".to_string()),
1307    }
1308}
1309
1310/// Optional block-level style attributes carried on a djot block through its
1311/// `{key=value}` block attributes. All `None` when the block has no such
1312/// attributes (or they were filtered out by [`DjotImportOptions`]).
1313#[derive(Debug, Clone, Default)]
1314struct DjotBlockStyle {
1315    alignment: Option<Alignment>,
1316    line_height: Option<i64>,
1317    non_breakable_lines: Option<bool>,
1318    direction: Option<TextDirection>,
1319    background_color: Option<String>,
1320}
1321
1322impl DjotBlockStyle {
1323    /// Overlay the `Some` fields of `other` onto `self`, leaving `self`'s
1324    /// existing values for any field `other` does not set. Used to combine a
1325    /// heading's enclosing-`Section` attributes with any on the heading itself.
1326    fn merge_from(&mut self, other: DjotBlockStyle) {
1327        if other.alignment.is_some() {
1328            self.alignment = other.alignment;
1329        }
1330        if other.line_height.is_some() {
1331            self.line_height = other.line_height;
1332        }
1333        if other.non_breakable_lines.is_some() {
1334            self.non_breakable_lines = other.non_breakable_lines;
1335        }
1336        if other.direction.is_some() {
1337            self.direction = other.direction;
1338        }
1339        if other.background_color.is_some() {
1340            self.background_color = other.background_color;
1341        }
1342    }
1343}
1344
1345/// Read the round-tripped block-style attributes off a djot block's
1346/// [`jotdown::Attributes`], honouring the import [`DjotImportOptions`]. Keys are
1347/// the model field names (`alignment`, `line_height`, `direction`,
1348/// `non_breakable_lines`, `background_color`); unrecognised values are ignored.
1349fn block_attrs_to_style(attrs: &jotdown::Attributes, opts: &DjotImportOptions) -> DjotBlockStyle {
1350    let mut style = DjotBlockStyle::default();
1351
1352    if opts.alignment
1353        && let Some(v) = attrs.get_value("alignment")
1354    {
1355        style.alignment = match v.to_string().as_str() {
1356            "left" => Some(Alignment::Left),
1357            "right" => Some(Alignment::Right),
1358            "center" => Some(Alignment::Center),
1359            "justify" => Some(Alignment::Justify),
1360            _ => None,
1361        };
1362    }
1363    if opts.line_height
1364        && let Some(v) = attrs.get_value("line_height")
1365    {
1366        style.line_height = v.to_string().parse::<i64>().ok();
1367    }
1368    if opts.direction
1369        && let Some(v) = attrs.get_value("direction")
1370    {
1371        style.direction = match v.to_string().as_str() {
1372            "ltr" => Some(TextDirection::LeftToRight),
1373            "rtl" => Some(TextDirection::RightToLeft),
1374            _ => None,
1375        };
1376    }
1377    if opts.non_breakable_lines
1378        && let Some(v) = attrs.get_value("non_breakable_lines")
1379    {
1380        style.non_breakable_lines = match v.to_string().as_str() {
1381            "true" => Some(true),
1382            "false" => Some(false),
1383            _ => None,
1384        };
1385    }
1386    if opts.background_color
1387        && let Some(v) = attrs.get_value("background_color")
1388    {
1389        style.background_color = Some(v.to_string());
1390    }
1391
1392    style
1393}
1394
1395/// Push a finished block into `elements`, applying the djot block-level fields
1396/// plus any round-tripped block-style attributes carried in `style`.
1397#[allow(clippy::too_many_arguments)]
1398fn djot_push_block(
1399    elements: &mut Vec<ParsedElement>,
1400    spans: Vec<ParsedSpan>,
1401    heading_level: Option<i64>,
1402    list_style: Option<ListStyle>,
1403    list_indent: u32,
1404    list_prefix: String,
1405    list_suffix: String,
1406    marker: Option<MarkerType>,
1407    is_code_block: bool,
1408    code_language: Option<String>,
1409    blockquote_depth: u32,
1410    style: DjotBlockStyle,
1411) {
1412    elements.push(ParsedElement::Block(ParsedBlock {
1413        spans,
1414        heading_level,
1415        list_style,
1416        list_indent,
1417        list_prefix,
1418        list_suffix,
1419        marker,
1420        is_code_block,
1421        code_language,
1422        blockquote_depth,
1423        line_height: style.line_height,
1424        non_breakable_lines: style.non_breakable_lines,
1425        direction: style.direction,
1426        background_color: style.background_color,
1427        alignment: style.alignment,
1428    }));
1429}
1430
1431/// Parse djot source into the shared [`ParsedElement`] intermediate, mirroring
1432/// [`parse_markdown`]. Uses the [`jotdown`] pull parser.
1433///
1434/// Constructs the document model cannot represent are dropped, and their text
1435/// content is discarded so it never leaks into the document: footnotes, math,
1436/// fenced divs, raw blocks/inline, thematic breaks, description lists,
1437/// captions, symbols, link-reference definitions, and highlight/`mark`. Inline
1438/// images keep their alt text as plain text (the image itself is not modelled),
1439/// matching the Markdown importer. Smart-punctuation events are normalised to
1440/// their canonical Unicode characters so the model→djot→model round-trip is a
1441/// fixpoint.
1442///
1443/// Standalone paragraphs and headings additionally carry the optional
1444/// block-style attributes selected by `options` — paragraph alignment, line
1445/// height, text direction, non-breakable lines and background color — read from
1446/// djot `{key=value}` block attributes (see [`DjotImportOptions`]). List items,
1447/// code blocks and table cells normalise their block styling away.
1448///
1449/// Known model limitations (normalised, not preserved on round-trip):
1450/// ordered-list start number, table column alignment, and list tight/loose.
1451pub fn parse_djot(djot: &str, options: &DjotImportOptions) -> Vec<ParsedElement> {
1452    use jotdown::{Container as C, Event as E, ListKind, Parser};
1453
1454    let mut elements: Vec<ParsedElement> = Vec::new();
1455    let mut current_spans: Vec<ParsedSpan> = Vec::new();
1456    let mut current_heading: Option<i64> = None;
1457    let mut is_code_block = false;
1458    let mut code_language: Option<String> = None;
1459    let mut blockquote_depth: u32 = 0;
1460    // Block-style attributes captured from a standalone paragraph/heading's djot
1461    // `{…}` block attributes, consumed when that block is flushed.
1462    let mut pending_style = DjotBlockStyle::default();
1463
1464    // Inline formatting state.
1465    let mut bold = false;
1466    let mut italic = false;
1467    let mut underline = false;
1468    let mut strikeout = false;
1469    let mut code = false;
1470    let mut superscript = false;
1471    let mut subscript = false;
1472    let mut link_href: Option<String> = None;
1473
1474    // List nesting: each entry is (style, prefix, suffix); depth = indent + 1.
1475    let mut list_stack: Vec<(ListStyle, String, String)> = Vec::new();
1476    // Context applied to the next flushed block while inside a list item.
1477    let mut cur_list_style: Option<ListStyle> = None;
1478    let mut cur_list_prefix = String::new();
1479    let mut cur_list_suffix = String::new();
1480    let mut cur_list_indent: u32 = 0;
1481    let mut cur_marker: Option<MarkerType> = None;
1482
1483    // Table accumulation.
1484    let mut in_table_cell = false;
1485    let mut table_rows: Vec<Vec<ParsedTableCell>> = Vec::new();
1486    let mut current_row: Vec<ParsedTableCell> = Vec::new();
1487    let mut current_cell_spans: Vec<ParsedSpan> = Vec::new();
1488    let mut table_header_rows: usize = 0;
1489    let mut row_is_head = false;
1490
1491    // Subtree-skip depth for unrepresentable containers (their entire content
1492    // is dropped). Incremented on the dropped container's `Start` and on every
1493    // nested `Start`; decremented on every `End`.
1494    let mut skip_depth: u32 = 0;
1495
1496    // Push one inline span carrying the current formatting state into the
1497    // active sink (table cell or block). A macro (not a closure) to avoid
1498    // borrowing `current_spans`/`current_cell_spans` across the formatting
1499    // state reads.
1500    macro_rules! push_text {
1501        ($t:expr) => {{
1502            let sp = ParsedSpan {
1503                text: ($t).to_string(),
1504                bold,
1505                italic,
1506                underline,
1507                strikeout,
1508                code,
1509                superscript,
1510                subscript,
1511                link_href: link_href.clone(),
1512            };
1513            if in_table_cell {
1514                current_cell_spans.push(sp);
1515            } else {
1516                current_spans.push(sp);
1517            }
1518        }};
1519    }
1520
1521    // Enter a list item, flushing any unterminated inline content first and
1522    // capturing the list context + task marker for the item's block.
1523    macro_rules! enter_item {
1524        ($marker:expr) => {{
1525            if !current_spans.is_empty() {
1526                djot_push_block(
1527                    &mut elements,
1528                    std::mem::take(&mut current_spans),
1529                    None,
1530                    cur_list_style.clone(),
1531                    cur_list_indent,
1532                    cur_list_prefix.clone(),
1533                    cur_list_suffix.clone(),
1534                    cur_marker.clone(),
1535                    false,
1536                    None,
1537                    blockquote_depth,
1538                    DjotBlockStyle::default(),
1539                );
1540            }
1541            let (style, prefix, suffix) = list_stack.last().cloned().unwrap_or((
1542                ListStyle::Disc,
1543                String::new(),
1544                String::new(),
1545            ));
1546            cur_list_style = Some(style);
1547            cur_list_prefix = prefix;
1548            cur_list_suffix = suffix;
1549            cur_list_indent = list_stack.len().saturating_sub(1) as u32;
1550            cur_marker = $marker;
1551        }};
1552    }
1553
1554    for event in Parser::new(djot) {
1555        if skip_depth > 0 {
1556            match event {
1557                E::Start(..) => skip_depth += 1,
1558                E::End(_) => skip_depth -= 1,
1559                _ => {}
1560            }
1561            continue;
1562        }
1563
1564        match event {
1565            // ── Transparent wrappers (unwrap, keep content) ──
1566            E::Start(C::Document, _) | E::End(C::Document) => {}
1567            E::Start(C::Section { .. }, attrs) => {
1568                // A heading's block attributes attach to its enclosing Section,
1569                // not the heading itself; capture them for the heading's flush.
1570                if list_stack.is_empty() {
1571                    pending_style.merge_from(block_attrs_to_style(&attrs, options));
1572                }
1573            }
1574            E::End(C::Section { .. }) => {}
1575            E::Start(C::Div { .. }, _) | E::End(C::Div { .. }) => {}
1576
1577            // ── Blockquote ──
1578            E::Start(C::Blockquote, _) => blockquote_depth += 1,
1579            E::End(C::Blockquote) => blockquote_depth = blockquote_depth.saturating_sub(1),
1580
1581            // ── Lists ──
1582            E::Start(C::List { kind, .. }, _) => {
1583                let (style, prefix, suffix) = match kind {
1584                    ListKind::Unordered(b) | ListKind::Task(b) => {
1585                        (djot_bullet_style(b), String::new(), String::new())
1586                    }
1587                    ListKind::Ordered {
1588                        numbering, style, ..
1589                    } => {
1590                        let (p, s) = djot_ordered_affixes(style);
1591                        (djot_ordered_style(numbering), p, s)
1592                    }
1593                };
1594                list_stack.push((style, prefix, suffix));
1595            }
1596            E::End(C::List { .. }) => {
1597                list_stack.pop();
1598                cur_list_style = None;
1599                cur_marker = None;
1600            }
1601            E::Start(C::ListItem, _) => enter_item!(None),
1602            E::Start(C::TaskListItem { checked }, _) => enter_item!(Some(if checked {
1603                MarkerType::Checked
1604            } else {
1605                MarkerType::Unchecked
1606            })),
1607            E::End(C::ListItem) | E::End(C::TaskListItem { .. }) => {
1608                // Tight item without a wrapping paragraph (defensive flush).
1609                if !current_spans.is_empty() {
1610                    djot_push_block(
1611                        &mut elements,
1612                        std::mem::take(&mut current_spans),
1613                        None,
1614                        cur_list_style.clone(),
1615                        cur_list_indent,
1616                        cur_list_prefix.clone(),
1617                        cur_list_suffix.clone(),
1618                        cur_marker.clone(),
1619                        false,
1620                        None,
1621                        blockquote_depth,
1622                        DjotBlockStyle::default(),
1623                    );
1624                }
1625                cur_list_style = None;
1626                cur_marker = None;
1627            }
1628
1629            // ── Headings, paragraphs, code blocks ──
1630            E::Start(C::Heading { level, .. }, attrs) => {
1631                current_heading = Some(level as i64);
1632                // The block-style attributes live on the enclosing Section;
1633                // merge any placed directly on the heading without clearing them.
1634                pending_style.merge_from(block_attrs_to_style(&attrs, options));
1635            }
1636            E::End(C::Heading { .. }) => {
1637                djot_push_block(
1638                    &mut elements,
1639                    std::mem::take(&mut current_spans),
1640                    current_heading.take(),
1641                    None,
1642                    0,
1643                    String::new(),
1644                    String::new(),
1645                    None,
1646                    false,
1647                    None,
1648                    blockquote_depth,
1649                    std::mem::take(&mut pending_style),
1650                );
1651            }
1652            E::Start(C::Paragraph, attrs) => {
1653                current_heading = None;
1654                // Block attributes only apply to standalone paragraphs;
1655                // list-item paragraphs normalise their styling away (matching
1656                // the exporter).
1657                pending_style = if list_stack.is_empty() {
1658                    block_attrs_to_style(&attrs, options)
1659                } else {
1660                    DjotBlockStyle::default()
1661                };
1662            }
1663            E::End(C::Paragraph) => {
1664                if !current_spans.is_empty() {
1665                    djot_push_block(
1666                        &mut elements,
1667                        std::mem::take(&mut current_spans),
1668                        None,
1669                        cur_list_style.clone(),
1670                        cur_list_indent,
1671                        cur_list_prefix.clone(),
1672                        cur_list_suffix.clone(),
1673                        cur_marker.clone(),
1674                        false,
1675                        None,
1676                        blockquote_depth,
1677                        std::mem::take(&mut pending_style),
1678                    );
1679                }
1680                cur_list_style = None;
1681                cur_marker = None;
1682            }
1683            E::Start(C::CodeBlock { language }, _) => {
1684                is_code_block = true;
1685                code_language = if language.is_empty() {
1686                    None
1687                } else {
1688                    Some(language.to_string())
1689                };
1690            }
1691            E::End(C::CodeBlock { .. }) => {
1692                // Strip the single trailing newline jotdown appends.
1693                if let Some(last) = current_spans.last_mut()
1694                    && last.text.ends_with('\n')
1695                {
1696                    last.text.pop();
1697                }
1698                djot_push_block(
1699                    &mut elements,
1700                    std::mem::take(&mut current_spans),
1701                    None,
1702                    None,
1703                    0,
1704                    String::new(),
1705                    String::new(),
1706                    None,
1707                    true,
1708                    code_language.take(),
1709                    blockquote_depth,
1710                    DjotBlockStyle::default(),
1711                );
1712                is_code_block = false;
1713            }
1714
1715            // ── Tables ──
1716            E::Start(C::Table, _) => {
1717                table_rows.clear();
1718                current_row.clear();
1719                current_cell_spans.clear();
1720                table_header_rows = 0;
1721            }
1722            E::End(C::Table) => {
1723                elements.push(ParsedElement::Table(ParsedTable {
1724                    header_rows: table_header_rows,
1725                    rows: std::mem::take(&mut table_rows),
1726                    blockquote_depth,
1727                }));
1728            }
1729            E::Start(C::TableRow { head }, _) => {
1730                row_is_head = head;
1731                current_row.clear();
1732            }
1733            E::End(C::TableRow { .. }) => {
1734                if row_is_head {
1735                    table_header_rows += 1;
1736                }
1737                table_rows.push(std::mem::take(&mut current_row));
1738            }
1739            E::Start(C::TableCell { .. }, _) => {
1740                in_table_cell = true;
1741                current_cell_spans.clear();
1742            }
1743            E::End(C::TableCell { .. }) => {
1744                in_table_cell = false;
1745                current_row.push(ParsedTableCell {
1746                    spans: std::mem::take(&mut current_cell_spans),
1747                });
1748            }
1749
1750            // ── Inline formatting ──
1751            E::Start(C::Strong, _) => bold = true,
1752            E::End(C::Strong) => bold = false,
1753            E::Start(C::Emphasis, _) => italic = true,
1754            E::End(C::Emphasis) => italic = false,
1755            E::Start(C::Verbatim, _) => code = true,
1756            E::End(C::Verbatim) => code = false,
1757            E::Start(C::Superscript, _) => superscript = true,
1758            E::End(C::Superscript) => superscript = false,
1759            E::Start(C::Subscript, _) => subscript = true,
1760            E::End(C::Subscript) => subscript = false,
1761            E::Start(C::Insert, _) => underline = true,
1762            E::End(C::Insert) => underline = false,
1763            E::Start(C::Delete, _) => strikeout = true,
1764            E::End(C::Delete) => strikeout = false,
1765            // Highlight/mark and bare spans have no model field — keep the text.
1766            E::Start(C::Mark, _) | E::End(C::Mark) => {}
1767            E::Start(C::Span, _) | E::End(C::Span) => {}
1768            E::Start(C::Link(dst, _), _) => link_href = Some(dst.to_string()),
1769            E::End(C::Link(..)) => link_href = None,
1770            // Inline images: keep alt text as plain text (image not modelled).
1771            E::Start(C::Image(..), _) | E::End(C::Image(..)) => {}
1772
1773            // ── Unrepresentable containers: drop the entire subtree ──
1774            E::Start(
1775                C::Footnote { .. }
1776                | C::Math { .. }
1777                | C::RawBlock { .. }
1778                | C::RawInline { .. }
1779                | C::DescriptionList
1780                | C::DescriptionDetails
1781                | C::DescriptionTerm
1782                | C::Caption
1783                | C::LinkDefinition { .. },
1784                _,
1785            ) => skip_depth = 1,
1786
1787            // ── Text + atoms ──
1788            E::Str(s) => push_text!(s.as_ref()),
1789            E::Softbreak => push_text!(" "),
1790            E::LeftSingleQuote => push_text!("\u{2018}"),
1791            E::RightSingleQuote => push_text!("\u{2019}"),
1792            E::LeftDoubleQuote => push_text!("\u{201C}"),
1793            E::RightDoubleQuote => push_text!("\u{201D}"),
1794            E::Ellipsis => push_text!("\u{2026}"),
1795            E::EnDash => push_text!("\u{2013}"),
1796            E::EmDash => push_text!("\u{2014}"),
1797            E::NonBreakingSpace => push_text!("\u{00A0}"),
1798            E::Hardbreak => {
1799                if in_table_cell {
1800                    push_text!(" ");
1801                } else if !current_spans.is_empty() {
1802                    // Mirrors the Markdown importer: a hard break splits the
1803                    // paragraph into a new block.
1804                    djot_push_block(
1805                        &mut elements,
1806                        std::mem::take(&mut current_spans),
1807                        None,
1808                        cur_list_style.clone(),
1809                        cur_list_indent,
1810                        cur_list_prefix.clone(),
1811                        cur_list_suffix.clone(),
1812                        cur_marker.clone(),
1813                        is_code_block,
1814                        code_language.clone(),
1815                        blockquote_depth,
1816                        pending_style.clone(),
1817                    );
1818                }
1819            }
1820            // Symbols, footnote refs, escapes, blanklines, thematic breaks and
1821            // dangling block attributes carry no representable content.
1822            E::Symbol(_) | E::FootnoteReference(_) => {}
1823            E::Escape | E::Blankline => {}
1824            E::ThematicBreak(_) | E::Attributes(_) => {}
1825
1826            // Ends of dropped containers (never reached at skip_depth 0) and any
1827            // future variants.
1828            _ => {}
1829        }
1830    }
1831
1832    // Flush any trailing inline content (defensive — Document End closes blocks).
1833    if !current_spans.is_empty() {
1834        djot_push_block(
1835            &mut elements,
1836            std::mem::take(&mut current_spans),
1837            current_heading.take(),
1838            cur_list_style.clone(),
1839            cur_list_indent,
1840            cur_list_prefix.clone(),
1841            cur_list_suffix.clone(),
1842            cur_marker.clone(),
1843            is_code_block,
1844            code_language.take(),
1845            blockquote_depth,
1846            std::mem::take(&mut pending_style),
1847        );
1848    }
1849
1850    // An empty document still yields a single empty paragraph (matches
1851    // `parse_markdown`).
1852    if elements.is_empty() {
1853        djot_push_block(
1854            &mut elements,
1855            vec![ParsedSpan {
1856                text: String::new(),
1857                ..Default::default()
1858            }],
1859            None,
1860            None,
1861            0,
1862            String::new(),
1863            String::new(),
1864            None,
1865            false,
1866            None,
1867            0,
1868            DjotBlockStyle::default(),
1869        );
1870    }
1871
1872    elements
1873}
1874
1875#[cfg(test)]
1876mod tests {
1877    use super::*;
1878
1879    /// Helper: flatten parse_markdown output to blocks for tests that don't care about tables.
1880    fn parse_markdown_blocks(md: &str) -> Vec<ParsedBlock> {
1881        ParsedElement::flatten_to_blocks(parse_markdown(md))
1882    }
1883
1884    #[test]
1885    fn test_parse_markdown_simple_paragraph() {
1886        let blocks = parse_markdown_blocks("Hello **world**");
1887        assert_eq!(blocks.len(), 1);
1888        assert!(blocks[0].spans.len() >= 2);
1889        // "Hello " is plain, "world" is bold
1890        let plain_span = blocks[0]
1891            .spans
1892            .iter()
1893            .find(|s| s.text.contains("Hello"))
1894            .unwrap();
1895        assert!(!plain_span.bold);
1896        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
1897        assert!(bold_span.bold);
1898    }
1899
1900    #[test]
1901    fn test_parse_markdown_heading() {
1902        let blocks = parse_markdown_blocks("# Title");
1903        assert_eq!(blocks.len(), 1);
1904        assert_eq!(blocks[0].heading_level, Some(1));
1905        assert_eq!(blocks[0].spans[0].text, "Title");
1906    }
1907
1908    #[test]
1909    fn test_parse_markdown_list() {
1910        let blocks = parse_markdown_blocks("- item1\n- item2");
1911        assert!(blocks.len() >= 2);
1912        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
1913        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
1914    }
1915
1916    /// Helper: extract (is_table, blockquote_depth) per element for nesting assertions.
1917    fn element_depths(elements: &[ParsedElement]) -> Vec<(bool, u32)> {
1918        elements
1919            .iter()
1920            .map(|e| match e {
1921                ParsedElement::Block(b) => (false, b.blockquote_depth),
1922                ParsedElement::Table(t) => (true, t.blockquote_depth),
1923            })
1924            .collect()
1925    }
1926
1927    #[test]
1928    fn test_parse_markdown_table_in_blockquote_records_depth() {
1929        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
1930        assert_eq!(element_depths(&elements), vec![(true, 1)]);
1931    }
1932
1933    #[test]
1934    fn test_parse_markdown_text_then_table_in_blockquote() {
1935        let elements = parse_markdown("> Para\n>\n> | a | b |\n> |---|---|\n> | c | d |");
1936        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 1)]);
1937    }
1938
1939    #[test]
1940    fn test_parse_markdown_table_after_blockquote_closes() {
1941        let elements = parse_markdown("> Para\n\n| a | b |\n|---|---|\n| c | d |");
1942        assert_eq!(element_depths(&elements), vec![(false, 1), (true, 0)]);
1943    }
1944
1945    #[test]
1946    fn test_parse_markdown_table_in_nested_blockquote() {
1947        let elements = parse_markdown(">> | a | b |\n>> |---|---|\n>> | c | d |");
1948        assert_eq!(element_depths(&elements), vec![(true, 2)]);
1949    }
1950
1951    #[test]
1952    fn test_parse_markdown_list_in_blockquote_records_depth() {
1953        let elements = parse_markdown("> - item1\n> - item2");
1954        let depths = element_depths(&elements);
1955        assert_eq!(depths, vec![(false, 1), (false, 1)]);
1956        for e in &elements {
1957            if let ParsedElement::Block(b) = e {
1958                assert_eq!(b.list_style, Some(ListStyle::Disc));
1959            }
1960        }
1961    }
1962
1963    #[test]
1964    fn test_parse_html_table_in_blockquote_records_depth() {
1965        let elements = parse_html_elements(
1966            "<blockquote><table><tr><th>A</th></tr><tr><td>x</td></tr></table></blockquote>",
1967        );
1968        assert_eq!(element_depths(&elements), vec![(true, 1)]);
1969    }
1970
1971    #[test]
1972    fn test_parse_html_table_after_blockquote() {
1973        let elements = parse_html_elements(
1974            "<blockquote><p>Para</p></blockquote><table><tr><td>X</td></tr></table>",
1975        );
1976        let depths = element_depths(&elements);
1977        // The blockquote paragraph carries depth 1; the table is outside (depth 0).
1978        assert!(depths.contains(&(false, 1)), "depths: {depths:?}");
1979        assert!(depths.contains(&(true, 0)), "depths: {depths:?}");
1980    }
1981
1982    #[test]
1983    fn test_flatten_to_blocks_propagates_blockquote_depth() {
1984        let elements = parse_markdown("> | a | b |\n> |---|---|\n> | c | d |");
1985        let blocks = ParsedElement::flatten_to_blocks(elements);
1986        assert!(!blocks.is_empty());
1987        for b in &blocks {
1988            assert_eq!(b.blockquote_depth, 1);
1989        }
1990    }
1991
1992    #[test]
1993    fn test_parse_html_simple() {
1994        let blocks = parse_html("<p>Hello <b>world</b></p>");
1995        assert_eq!(blocks.len(), 1);
1996        assert!(blocks[0].spans.len() >= 2);
1997        let bold_span = blocks[0].spans.iter().find(|s| s.text == "world").unwrap();
1998        assert!(bold_span.bold);
1999    }
2000
2001    #[test]
2002    fn test_parse_html_multiple_paragraphs() {
2003        let blocks = parse_html("<p>A</p><p>B</p>");
2004        assert_eq!(blocks.len(), 2);
2005    }
2006
2007    #[test]
2008    fn test_parse_html_heading() {
2009        let blocks = parse_html("<h2>Subtitle</h2>");
2010        assert_eq!(blocks.len(), 1);
2011        assert_eq!(blocks[0].heading_level, Some(2));
2012    }
2013
2014    #[test]
2015    fn test_parse_html_list() {
2016        let blocks = parse_html("<ul><li>one</li><li>two</li></ul>");
2017        assert!(blocks.len() >= 2);
2018        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2019    }
2020
2021    #[test]
2022    fn test_parse_markdown_code_block() {
2023        let blocks = parse_markdown_blocks("```\nfn main() {}\n```");
2024        assert_eq!(blocks.len(), 1);
2025        assert!(blocks[0].is_code_block);
2026        assert!(blocks[0].spans[0].code);
2027        // pulldown-cmark appends a trailing \n to code block text — verify it's stripped
2028        let text: String = blocks[0].spans.iter().map(|s| s.text.as_str()).collect();
2029        assert_eq!(
2030            text, "fn main() {}",
2031            "code block text should not have trailing newline"
2032        );
2033    }
2034
2035    #[test]
2036    fn test_parse_markdown_nested_formatting() {
2037        let blocks = parse_markdown_blocks("***bold italic***");
2038        assert_eq!(blocks.len(), 1);
2039        let span = &blocks[0].spans[0];
2040        assert!(span.bold);
2041        assert!(span.italic);
2042    }
2043
2044    #[test]
2045    fn test_parse_markdown_link() {
2046        let blocks = parse_markdown_blocks("[click](http://example.com)");
2047        assert_eq!(blocks.len(), 1);
2048        let span = &blocks[0].spans[0];
2049        assert_eq!(span.text, "click");
2050        assert_eq!(span.link_href, Some("http://example.com".to_string()));
2051    }
2052
2053    #[test]
2054    fn test_parse_markdown_empty() {
2055        let blocks = parse_markdown_blocks("");
2056        assert_eq!(blocks.len(), 1);
2057        assert!(blocks[0].spans[0].text.is_empty());
2058    }
2059
2060    #[test]
2061    fn test_parse_html_empty() {
2062        let blocks = parse_html("");
2063        assert_eq!(blocks.len(), 1);
2064        assert!(blocks[0].spans[0].text.is_empty());
2065    }
2066
2067    #[test]
2068    fn test_parse_html_nested_formatting() {
2069        let blocks = parse_html("<p><b><i>bold italic</i></b></p>");
2070        assert_eq!(blocks.len(), 1);
2071        let span = &blocks[0].spans[0];
2072        assert!(span.bold);
2073        assert!(span.italic);
2074    }
2075
2076    #[test]
2077    fn test_parse_html_link() {
2078        let blocks = parse_html("<p><a href=\"http://example.com\">click</a></p>");
2079        assert_eq!(blocks.len(), 1);
2080        let span = &blocks[0].spans[0];
2081        assert_eq!(span.text, "click");
2082        assert_eq!(span.link_href, Some("http://example.com".to_string()));
2083    }
2084
2085    #[test]
2086    fn test_parse_html_ordered_list() {
2087        let blocks = parse_html("<ol><li>first</li><li>second</li></ol>");
2088        assert!(blocks.len() >= 2);
2089        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2090    }
2091
2092    #[test]
2093    fn test_parse_markdown_ordered_list() {
2094        let blocks = parse_markdown_blocks("1. first\n2. second");
2095        assert!(blocks.len() >= 2);
2096        assert_eq!(blocks[0].list_style, Some(ListStyle::Decimal));
2097    }
2098
2099    #[test]
2100    fn test_parse_html_blockquote_nested() {
2101        let blocks = parse_html("<p>before</p><blockquote>quoted</blockquote><p>after</p>");
2102        assert!(blocks.len() >= 3);
2103    }
2104
2105    #[test]
2106    fn test_parse_block_styles_line_height() {
2107        let styles = parse_block_styles("line-height: 1.5");
2108        assert_eq!(styles.line_height, Some(1500));
2109    }
2110
2111    #[test]
2112    fn test_parse_block_styles_direction_rtl() {
2113        let styles = parse_block_styles("direction: rtl");
2114        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2115    }
2116
2117    #[test]
2118    fn test_parse_block_styles_background_color() {
2119        let styles = parse_block_styles("background-color: #ff0000");
2120        assert_eq!(styles.background_color, Some("#ff0000".to_string()));
2121    }
2122
2123    #[test]
2124    fn test_parse_block_styles_white_space_pre() {
2125        let styles = parse_block_styles("white-space: pre");
2126        assert_eq!(styles.non_breakable_lines, Some(true));
2127    }
2128
2129    #[test]
2130    fn test_parse_block_styles_multiple() {
2131        let styles = parse_block_styles("line-height: 2.0; direction: rtl; background-color: blue");
2132        assert_eq!(styles.line_height, Some(2000));
2133        assert_eq!(styles.direction, Some(TextDirection::RightToLeft));
2134        assert_eq!(styles.background_color, Some("blue".to_string()));
2135    }
2136
2137    #[test]
2138    fn test_parse_html_block_styles_extracted() {
2139        let blocks = parse_html(
2140            r#"<p style="line-height: 1.5; direction: rtl; background-color: #ccc">text</p>"#,
2141        );
2142        assert_eq!(blocks.len(), 1);
2143        assert_eq!(blocks[0].line_height, Some(1500));
2144        assert_eq!(blocks[0].direction, Some(TextDirection::RightToLeft));
2145        assert_eq!(blocks[0].background_color, Some("#ccc".to_string()));
2146    }
2147
2148    #[test]
2149    fn test_parse_html_white_space_pre() {
2150        let blocks = parse_html(r#"<p style="white-space: pre">code</p>"#);
2151        assert_eq!(blocks.len(), 1);
2152        assert_eq!(blocks[0].non_breakable_lines, Some(true));
2153    }
2154
2155    #[test]
2156    fn test_parse_html_no_styles_returns_none() {
2157        let blocks = parse_html("<p>plain</p>");
2158        assert_eq!(blocks.len(), 1);
2159        assert_eq!(blocks[0].line_height, None);
2160        assert_eq!(blocks[0].direction, None);
2161        assert_eq!(blocks[0].background_color, None);
2162        assert_eq!(blocks[0].non_breakable_lines, None);
2163    }
2164
2165    #[test]
2166    fn test_parse_markdown_nested_list_indent() {
2167        let md = "- top\n  - nested\n    - deep";
2168        let blocks = parse_markdown_blocks(md);
2169        assert_eq!(blocks.len(), 3);
2170        assert_eq!(blocks[0].list_style, Some(ListStyle::Disc));
2171        assert_eq!(blocks[0].list_indent, 0);
2172        assert_eq!(blocks[1].list_style, Some(ListStyle::Disc));
2173        assert_eq!(blocks[1].list_indent, 1);
2174        assert_eq!(blocks[2].list_style, Some(ListStyle::Disc));
2175        assert_eq!(blocks[2].list_indent, 2);
2176    }
2177
2178    #[test]
2179    fn test_parse_markdown_nested_ordered_list_indent() {
2180        let md = "1. first\n   1. nested\n   2. nested2";
2181        let blocks = parse_markdown_blocks(md);
2182        assert_eq!(blocks.len(), 3);
2183        assert_eq!(blocks[0].list_indent, 0);
2184        assert_eq!(blocks[1].list_indent, 1);
2185        assert_eq!(blocks[2].list_indent, 1);
2186    }
2187
2188    #[test]
2189    fn test_parse_html_nested_list_indent() {
2190        let html = "<ul><li>top</li><ul><li>nested</li></ul></ul>";
2191        let blocks = parse_html(html);
2192        assert!(blocks.len() >= 2);
2193        assert_eq!(blocks[0].list_indent, 0);
2194        assert_eq!(blocks[1].list_indent, 1);
2195    }
2196
2197    #[test]
2198    fn test_parse_markdown_table() {
2199        let md = "| A | B |\n|---|---|\n| 1 | 2 |";
2200        let elements = parse_markdown(md);
2201        assert_eq!(elements.len(), 1);
2202        match &elements[0] {
2203            ParsedElement::Table(table) => {
2204                assert_eq!(table.header_rows, 1);
2205                assert_eq!(table.rows.len(), 2); // 1 header + 1 body
2206                // Header row
2207                assert_eq!(table.rows[0].len(), 2);
2208                assert_eq!(table.rows[0][0].spans[0].text, "A");
2209                assert_eq!(table.rows[0][1].spans[0].text, "B");
2210                // Body row
2211                assert_eq!(table.rows[1].len(), 2);
2212                assert_eq!(table.rows[1][0].spans[0].text, "1");
2213                assert_eq!(table.rows[1][1].spans[0].text, "2");
2214            }
2215            _ => panic!("Expected ParsedElement::Table"),
2216        }
2217    }
2218
2219    #[test]
2220    fn test_parse_markdown_table_with_formatting() {
2221        let md = "| **bold** | `code` | *italic* |\n|---|---|---|\n| ~~strike~~ | plain | [link](http://x.com) |";
2222        let elements = parse_markdown(md);
2223        assert_eq!(elements.len(), 1);
2224        match &elements[0] {
2225            ParsedElement::Table(table) => {
2226                assert_eq!(table.rows.len(), 2);
2227                // Header: bold cell
2228                assert!(table.rows[0][0].spans[0].bold);
2229                // Header: code cell
2230                assert!(table.rows[0][1].spans[0].code);
2231                // Header: italic cell
2232                assert!(table.rows[0][2].spans[0].italic);
2233                // Body: strikeout cell
2234                assert!(table.rows[1][0].spans[0].strikeout);
2235                // Body: link cell
2236                assert_eq!(
2237                    table.rows[1][2].spans[0].link_href,
2238                    Some("http://x.com".to_string())
2239                );
2240            }
2241            _ => panic!("Expected ParsedElement::Table"),
2242        }
2243    }
2244
2245    #[test]
2246    fn test_parse_markdown_mixed_content_with_table() {
2247        let md = "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter";
2248        let elements = parse_markdown(md);
2249        assert_eq!(elements.len(), 3);
2250        assert!(matches!(&elements[0], ParsedElement::Block(_)));
2251        assert!(matches!(&elements[1], ParsedElement::Table(_)));
2252        assert!(matches!(&elements[2], ParsedElement::Block(_)));
2253    }
2254}
2255
2256#[cfg(test)]
2257mod djot_tests {
2258    use super::*;
2259    use crate::entities::MarkerType;
2260
2261    fn blocks(d: &str) -> Vec<ParsedBlock> {
2262        ParsedElement::flatten_to_blocks(parse_djot(d, &DjotImportOptions::default()))
2263    }
2264
2265    fn first_span_with(b: &ParsedBlock, pred: impl Fn(&ParsedSpan) -> bool) -> &ParsedSpan {
2266        b.spans.iter().find(|s| pred(s)).expect("span not found")
2267    }
2268
2269    #[test]
2270    fn paragraph_bold_italic() {
2271        let b = blocks("normal *bold* _italic_");
2272        assert_eq!(b.len(), 1);
2273        assert!(first_span_with(&b[0], |s| s.text == "bold").bold);
2274        assert!(first_span_with(&b[0], |s| s.text == "italic").italic);
2275    }
2276
2277    #[test]
2278    fn heading_levels() {
2279        assert_eq!(blocks("# H1")[0].heading_level, Some(1));
2280        assert_eq!(blocks("### H3")[0].heading_level, Some(3));
2281        assert_eq!(blocks("###### H6")[0].heading_level, Some(6));
2282    }
2283
2284    #[test]
2285    fn unordered_bullet_styles_are_distinct() {
2286        assert_eq!(blocks("- a")[0].list_style, Some(ListStyle::Disc));
2287        assert_eq!(blocks("* a")[0].list_style, Some(ListStyle::Circle));
2288        assert_eq!(blocks("+ a")[0].list_style, Some(ListStyle::Square));
2289    }
2290
2291    #[test]
2292    fn ordered_delimiters() {
2293        let period = blocks("1. a");
2294        assert_eq!(period[0].list_style, Some(ListStyle::Decimal));
2295        assert_eq!(period[0].list_prefix, "");
2296        assert_eq!(period[0].list_suffix, ".");
2297
2298        let paren = blocks("1) a");
2299        assert_eq!(paren[0].list_suffix, ")");
2300        assert_eq!(paren[0].list_prefix, "");
2301
2302        let paren_paren = blocks("(1) a");
2303        assert_eq!(paren_paren[0].list_prefix, "(");
2304        assert_eq!(paren_paren[0].list_suffix, ")");
2305    }
2306
2307    #[test]
2308    fn task_list_markers() {
2309        let b = blocks("- [ ] a\n- [x] b");
2310        assert_eq!(b.len(), 2);
2311        assert_eq!(b[0].marker, Some(MarkerType::Unchecked));
2312        assert_eq!(b[1].marker, Some(MarkerType::Checked));
2313    }
2314
2315    #[test]
2316    fn code_block_with_language() {
2317        let b = blocks("```rust\nfn main() {}\n```");
2318        assert_eq!(b.len(), 1);
2319        assert!(b[0].is_code_block);
2320        assert_eq!(b[0].code_language.as_deref(), Some("rust"));
2321        let text: String = b[0].spans.iter().map(|s| s.text.as_str()).collect();
2322        assert_eq!(text, "fn main() {}");
2323    }
2324
2325    #[test]
2326    fn link_href() {
2327        let b = blocks("[text](http://example.com)");
2328        let s = first_span_with(&b[0], |s| s.text == "text");
2329        assert_eq!(s.link_href.as_deref(), Some("http://example.com"));
2330    }
2331
2332    #[test]
2333    fn superscript_subscript() {
2334        assert!(first_span_with(&blocks("a^b^")[0], |s| s.text == "b").superscript);
2335        assert!(first_span_with(&blocks("a~b~")[0], |s| s.text == "b").subscript);
2336    }
2337
2338    #[test]
2339    fn delete_insert_verbatim() {
2340        assert!(first_span_with(&blocks("{-x-}")[0], |s| s.text == "x").strikeout);
2341        assert!(first_span_with(&blocks("{+x+}")[0], |s| s.text == "x").underline);
2342        assert!(first_span_with(&blocks("`x`")[0], |s| s.text == "x").code);
2343    }
2344
2345    #[test]
2346    fn blockquote_depth() {
2347        let els = parse_djot("> quoted", &DjotImportOptions::default());
2348        match &els[0] {
2349            ParsedElement::Block(b) => assert_eq!(b.blockquote_depth, 1),
2350            _ => panic!("expected block"),
2351        }
2352    }
2353
2354    #[test]
2355    fn nested_list_indent() {
2356        // Djot nests a sub-list only when a blank line separates it from the
2357        // parent item and it is indented to the parent's content column
2358        // (2 spaces per level). Without the blank line the markers fold into
2359        // the paragraph as lazy continuation.
2360        let b = blocks("- a\n\n  - b\n\n    - c");
2361        assert_eq!(b.len(), 3);
2362        assert_eq!(b[0].list_indent, 0);
2363        assert_eq!(b[1].list_indent, 1);
2364        assert_eq!(b[2].list_indent, 2);
2365    }
2366
2367    #[test]
2368    fn table_parsed_as_table() {
2369        let els = parse_djot(
2370            "| a | b |\n|---|---|\n| c | d |",
2371            &DjotImportOptions::default(),
2372        );
2373        assert_eq!(els.len(), 1);
2374        match &els[0] {
2375            ParsedElement::Table(t) => {
2376                assert_eq!(t.header_rows, 1);
2377                assert_eq!(t.rows.len(), 2);
2378                assert_eq!(t.rows[0][0].spans[0].text, "a");
2379                assert_eq!(t.rows[1][1].spans[0].text, "d");
2380            }
2381            _ => panic!("expected table"),
2382        }
2383    }
2384
2385    #[test]
2386    fn smart_punctuation_normalised_to_unicode() {
2387        let text: String = blocks("a... b---c")[0]
2388            .spans
2389            .iter()
2390            .map(|s| s.text.as_str())
2391            .collect();
2392        assert!(text.contains('\u{2026}'), "ellipsis: {text:?}");
2393        assert!(text.contains('\u{2014}'), "em dash: {text:?}");
2394    }
2395
2396    #[test]
2397    fn unrepresentable_constructs_dropped_without_leaking_text() {
2398        // Thematic break between two paragraphs: no extra block, no stray text.
2399        let b = blocks("para1\n\n---\n\npara2");
2400        assert_eq!(b.len(), 2);
2401        assert_eq!(
2402            b[0].spans
2403                .iter()
2404                .map(|s| s.text.as_str())
2405                .collect::<String>(),
2406            "para1"
2407        );
2408        assert_eq!(
2409            b[1].spans
2410                .iter()
2411                .map(|s| s.text.as_str())
2412                .collect::<String>(),
2413            "para2"
2414        );
2415
2416        // Fenced div is unwrapped: its content survives, the fence does not.
2417        let d = blocks(":::\ninside\n:::");
2418        let joined: String = d
2419            .iter()
2420            .flat_map(|b| b.spans.iter())
2421            .map(|s| s.text.as_str())
2422            .collect();
2423        assert_eq!(joined, "inside");
2424
2425        // Inline math content is dropped, surrounding text kept.
2426        let m = blocks("before $`E=mc^2` after");
2427        let joined: String = m
2428            .iter()
2429            .flat_map(|b| b.spans.iter())
2430            .map(|s| s.text.as_str())
2431            .collect();
2432        assert!(joined.contains("before"), "{joined:?}");
2433        assert!(joined.contains("after"), "{joined:?}");
2434        assert!(!joined.contains("E=mc"), "math leaked: {joined:?}");
2435    }
2436
2437    #[test]
2438    fn empty_document_yields_one_empty_block() {
2439        let b = blocks("");
2440        assert_eq!(b.len(), 1);
2441        assert!(b[0].spans.iter().all(|s| s.text.is_empty()));
2442    }
2443
2444    #[test]
2445    fn block_attributes_parse_into_block() {
2446        let b = blocks(
2447            "{alignment=center line_height=1500 direction=rtl non_breakable_lines=true background_color=\"#ff0000\"}\nhello",
2448        );
2449        assert_eq!(b.len(), 1);
2450        assert_eq!(b[0].alignment, Some(Alignment::Center));
2451        assert_eq!(b[0].line_height, Some(1500));
2452        assert_eq!(b[0].direction, Some(TextDirection::RightToLeft));
2453        assert_eq!(b[0].non_breakable_lines, Some(true));
2454        assert_eq!(b[0].background_color, Some("#ff0000".to_string()));
2455    }
2456
2457    #[test]
2458    fn block_attributes_on_heading() {
2459        let b = blocks("{alignment=right}\n# Title");
2460        assert_eq!(b[0].heading_level, Some(1));
2461        assert_eq!(b[0].alignment, Some(Alignment::Right));
2462    }
2463
2464    #[test]
2465    fn block_attributes_respect_import_options() {
2466        // With every optional attribute disabled, the `{…}` block attributes are
2467        // parsed and discarded — only the core paragraph survives.
2468        let src = "{alignment=center line_height=1500}\nhello";
2469        let b = ParsedElement::flatten_to_blocks(parse_djot(src, &DjotImportOptions::none()));
2470        assert_eq!(b[0].alignment, None);
2471        assert_eq!(b[0].line_height, None);
2472        assert_eq!(
2473            b[0].spans
2474                .iter()
2475                .map(|s| s.text.as_str())
2476                .collect::<String>(),
2477            "hello"
2478        );
2479    }
2480
2481    #[test]
2482    fn list_item_block_attributes_are_dropped() {
2483        // Block attributes only bind to standalone paragraphs/headings; a list
2484        // item normalises them away (symmetric with the exporter).
2485        let b = blocks("{alignment=center}\n- item");
2486        assert!(b.iter().all(|blk| blk.alignment.is_none()));
2487    }
2488
2489    #[test]
2490    fn unknown_alignment_value_is_ignored() {
2491        let b = blocks("{alignment=sideways}\nhello");
2492        assert_eq!(b[0].alignment, None);
2493    }
2494}