Skip to main content

docling/backend/
markdown.rs

1//! Markdown backend (CommonMark via `pulldown-cmark`).
2//!
3//! Two output modes, selected by [`MarkdownBackend::strict`]:
4//!
5//! * **legacy** (`strict = false`, the default) reproduces docling's
6//!   `MarkdownDocumentBackend` (marko) + docling-core serializer round-trip,
7//!   quirks and all: inline content split into "runs" rejoined with single
8//!   spaces (`***x***.` → `***x*** .`), `_`/`&<>` escaping, HTML entities
9//!   decoded then re-escaped, and a lone inline-code paragraph turned into a
10//!   code block.
11//! * **strict** (`strict = true`) emits cleaner, more conformant Markdown:
12//!   inline text is kept verbatim (no run-spacing, no escaping), inline code is
13//!   left literal, and a lone code span stays an inline-code paragraph.
14//!
15//! Table cells are plain text in both modes; pipe/newline escaping is done by
16//! the serializer.
17
18use docling_core::{DoclingDocument, Node, Table};
19use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
20
21use crate::backend::DeclarativeBackend;
22use crate::error::ConversionError;
23use crate::source::SourceDocument;
24
25#[derive(Default)]
26pub struct MarkdownBackend {
27    /// Emit cleaner, more conformant Markdown rather than docling-legacy output.
28    pub strict: bool,
29}
30
31/// Is this trimmed line a GFM table delimiter row (`--- | :---:` …)? Every
32/// `|`-separated cell must be `-`s with optional edge colons, and at least one
33/// pipe must be present (a bare `---` is a thematic break / setext underline).
34fn is_delimiter_row(line: &str) -> bool {
35    let line = line.trim();
36    if !line.contains('|') {
37        return false;
38    }
39    line.trim_start_matches('|')
40        .trim_end_matches('|')
41        .split('|')
42        .all(|cell| {
43            let dashes = cell.trim().trim_start_matches(':').trim_end_matches(':');
44            !dashes.is_empty() && dashes.bytes().all(|b| b == b'-')
45        })
46}
47
48/// GFM leaves a table row's edge pipes optional, but pulldown-cmark only
49/// enters table mode on a leading one — a table written `Region | Q1` over a
50/// `--- | ---` delimiter row came out as plain text (docling 2.122 reads it,
51/// docling#3817). Detect that shape — a pipe-carrying line whose next line is
52/// a delimiter row with the same cell count — and normalize the whole block's
53/// edge pipes so pulldown parses it like the canonical spelling. Fenced code
54/// is left untouched, and input with no such table borrows unchanged.
55fn normalize_table_edge_pipes(text: &str) -> std::borrow::Cow<'_, str> {
56    let cell_count = |line: &str| {
57        line.trim()
58            .trim_start_matches('|')
59            .trim_end_matches('|')
60            .split('|')
61            .count()
62    };
63    let is_fence = |line: &str| {
64        let t = line.trim_start();
65        t.starts_with("```") || t.starts_with("~~~")
66    };
67
68    let lines: Vec<&str> = text.split('\n').collect();
69    let mut needs_fix = false;
70    {
71        let mut in_fence = false;
72        for w in lines.windows(2) {
73            let head = w[0].trim_end_matches('\r');
74            if is_fence(head) {
75                in_fence = !in_fence;
76                continue;
77            }
78            let delim = w[1].trim_end_matches('\r');
79            if !in_fence
80                && head.contains('|')
81                && !head.trim_start().starts_with('|')
82                && is_delimiter_row(delim)
83                && cell_count(head) == cell_count(delim)
84            {
85                needs_fix = true;
86                break;
87            }
88        }
89    }
90    if !needs_fix {
91        return std::borrow::Cow::Borrowed(text);
92    }
93
94    let mut out: Vec<String> = Vec::with_capacity(lines.len());
95    let mut in_fence = false;
96    let mut i = 0;
97    while i < lines.len() {
98        let head = lines[i].trim_end_matches('\r');
99        if is_fence(head) {
100            in_fence = !in_fence;
101            out.push(lines[i].to_string());
102            i += 1;
103            continue;
104        }
105        let is_table_start = !in_fence
106            && head.contains('|')
107            && !head.trim_start().starts_with('|')
108            && lines
109                .get(i + 1)
110                .is_some_and(|d| is_delimiter_row(d.trim_end_matches('\r')))
111            && cell_count(head) == cell_count(lines[i + 1].trim_end_matches('\r'));
112        if !is_table_start {
113            out.push(lines[i].to_string());
114            i += 1;
115            continue;
116        }
117        // The block runs while lines keep carrying pipes; each gets both edge
118        // pipes (rows already carrying one keep it).
119        while i < lines.len() {
120            let (body, cr) = match lines[i].strip_suffix('\r') {
121                Some(b) => (b, "\r"),
122                None => (lines[i], ""),
123            };
124            if !body.contains('|') || body.trim().is_empty() {
125                break;
126            }
127            let t = body.trim();
128            let mut fixed = String::with_capacity(t.len() + 4);
129            if !t.starts_with('|') {
130                fixed.push_str("| ");
131            }
132            fixed.push_str(t);
133            if !t.ends_with('|') {
134                fixed.push_str(" |");
135            }
136            fixed.push_str(cr);
137            out.push(fixed);
138            i += 1;
139        }
140    }
141    std::borrow::Cow::Owned(out.join("\n"))
142}
143
144impl DeclarativeBackend for MarkdownBackend {
145    fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
146        // GFM makes a table row's edge pipes optional, but pulldown-cmark only
147        // recognizes a table whose header starts with one. docling 2.122
148        // (docling#3817) reads the pipe-less spelling; normalizing the edges
149        // up front lets pulldown parse the same tables. `text` stays borrowed
150        // (no allocation) when nothing needs normalizing — the common case.
151        let text = source.text()?;
152        let normalized = normalize_table_edge_pipes(&text);
153        let text = normalized.as_ref();
154        let mut opts = Options::empty();
155        opts.insert(Options::ENABLE_TABLES);
156        opts.insert(Options::ENABLE_STRIKETHROUGH);
157
158        // Merge only *contiguous* text events. pulldown splits both `[11]`
159        // (failed link) and `2\.` (escape) into multiple text events, but the
160        // escape leaves an offset gap (the backslash). marko keeps `[11]` as one
161        // run yet treats the escaped `.` as a separate run — so merging the
162        // contiguous pieces and leaving gapped ones split reproduces both.
163        // Inside a table the gap rule does not apply: docling rebuilds a cell
164        // from the raw source line (`_close_table` splits it on `|`), so a
165        // backslash-escaped `\|` — or any other escape — is cell content with
166        // nothing around it (docling#4313: `a\|b` is the cell `a|b`).
167        let raw: Vec<(Event, std::ops::Range<usize>)> =
168            Parser::new_ext(text, opts).into_offset_iter().collect();
169        let mut events: Vec<Event> = Vec::with_capacity(raw.len());
170        let mut k = 0;
171        let mut in_table = false;
172        while k < raw.len() {
173            match &raw[k].0 {
174                Event::Start(Tag::Table(_)) => in_table = true,
175                Event::End(TagEnd::Table) => in_table = false,
176                _ => {}
177            }
178            if matches!(raw[k].0, Event::Text(_)) {
179                let mut merged = String::new();
180                let mut end = raw[k].1.start;
181                while let Some((Event::Text(t), range)) = raw.get(k) {
182                    let escape_gap = in_table
183                        && range.start == end + 1
184                        && text.as_bytes().get(end) == Some(&b'\\');
185                    if range.start != end && !escape_gap {
186                        break;
187                    }
188                    merged.push_str(t);
189                    end = range.end;
190                    k += 1;
191                }
192                events.push(Event::Text(merged.into()));
193            } else {
194                events.push(raw[k].0.clone());
195                k += 1;
196            }
197        }
198
199        let mut doc = DoclingDocument::new(&source.name);
200        let mut i = 0;
201        self.parse_blocks(&events, &mut i, &mut doc.nodes, 0, Stop::Eof, 0);
202        // The JSON serializes docling's item tree — marko's CommonMark view of
203        // the document, walked as upstream does ([`super::md_tree`]); the flat
204        // nodes above stay the source for every other serializer. A document
205        // with a raw HTML block keeps the flat export (see the module docs).
206        doc.tree = super::md_tree::build_tree(text);
207        Ok(doc)
208    }
209}
210
211/// What terminates a `parse_blocks` run.
212#[derive(Clone, Copy, PartialEq)]
213enum Stop {
214    Eof,
215    BlockQuote,
216}
217
218/// Deepest block nesting (block quotes, lists) the parser descends into —
219/// markdown-it's `maxNesting`, which is what docling's Markdown backend runs
220/// on. Deeper structure is skipped: `parse_blocks`/`parse_list`/`parse_item`
221/// recurse per level, and 50 000 `>` (48 KB) or a 5 000-level list overflowed
222/// the stack — an abort, not an error.
223const MAX_NESTING: u16 = 100;
224
225/// Skip the balanced event subtree that starts at the `Start` event under
226/// `*i` (inclusive of its `End`), without recursion.
227pub(super) fn skip_subtree(events: &[Event], i: &mut usize) {
228    let mut depth = 0usize;
229    while *i < events.len() {
230        match &events[*i] {
231            Event::Start(_) => depth += 1,
232            Event::End(_) => {
233                depth = depth.saturating_sub(1);
234                if depth == 0 {
235                    *i += 1;
236                    return;
237                }
238            }
239            _ => {}
240        }
241        *i += 1;
242    }
243}
244
245impl MarkdownBackend {
246    // -----------------------------------------------------------------------
247    // Block structure
248    // -----------------------------------------------------------------------
249
250    fn parse_blocks(
251        &self,
252        events: &[Event],
253        i: &mut usize,
254        out: &mut Vec<Node>,
255        list_level: u8,
256        stop: Stop,
257        depth: u16,
258    ) {
259        while *i < events.len() {
260            match &events[*i] {
261                Event::Start(Tag::BlockQuote(_) | Tag::List(_)) if depth >= MAX_NESTING => {
262                    skip_subtree(events, i);
263                }
264                Event::End(TagEnd::BlockQuote(_)) if stop == Stop::BlockQuote => {
265                    *i += 1;
266                    return;
267                }
268                // Any other End belongs to a container handled elsewhere — skip
269                // it rather than abandoning the rest of the document.
270                Event::End(_) => {
271                    *i += 1;
272                }
273                Event::Start(Tag::HtmlBlock) => {
274                    *i += 1;
275                    let mut html = String::new();
276                    while *i < events.len() && !matches!(events[*i], Event::End(TagEnd::HtmlBlock))
277                    {
278                        if let Event::Html(t) = &events[*i] {
279                            html.push_str(t);
280                        }
281                        *i += 1;
282                    }
283                    consume_end(events, i);
284                    // docling parses embedded raw-HTML blocks; reuse the HTML backend.
285                    // Embedded HTML never fetches images.
286                    super::html::append_fragment(&html, out, &super::images::NoFetch);
287                }
288                Event::Start(Tag::BlockQuote(_)) => {
289                    *i += 1;
290                    self.parse_blocks(events, i, out, list_level, Stop::BlockQuote, depth + 1);
291                }
292                Event::Start(Tag::Paragraph) => {
293                    // Legacy: a lone inline code span becomes a code block.
294                    if !self.strict
295                        && matches!(events.get(*i + 1), Some(Event::Code(_)))
296                        && matches!(events.get(*i + 2), Some(Event::End(TagEnd::Paragraph)))
297                    {
298                        if let Some(Event::Code(c)) = events.get(*i + 1) {
299                            let text = unescape_entities(c.trim());
300                            if !text.is_empty() {
301                                out.push(Node::Code {
302                                    language: None,
303                                    text,
304                                    orig: None,
305                                    pretty: None,
306                                });
307                            }
308                        }
309                        *i += 3;
310                        continue;
311                    }
312                    *i += 1;
313                    let runs = self.collect_inline(events, i, false);
314                    consume_end(events, i);
315                    let text = self.join_runs(&runs, false);
316                    if !text.is_empty() {
317                        out.push(Node::Paragraph { text });
318                    }
319                }
320                Event::Start(Tag::Heading { level, .. }) => {
321                    let lvl = heading_level(*level);
322                    *i += 1;
323                    let runs = self.collect_inline(events, i, false);
324                    consume_end(events, i);
325                    let text = self.join_runs(&runs, false);
326                    if !text.is_empty() {
327                        out.push(Node::Heading { level: lvl, text });
328                    }
329                }
330                Event::Start(Tag::List(start)) => {
331                    let ordered = start.is_some();
332                    let start_num = start.unwrap_or(1);
333                    *i += 1;
334                    self.parse_list(events, i, out, list_level, ordered, start_num, depth + 1);
335                }
336                Event::Start(Tag::CodeBlock(kind)) => {
337                    let language = match kind {
338                        CodeBlockKind::Fenced(info) => {
339                            let lang = info.split_whitespace().next().unwrap_or("");
340                            (!lang.is_empty()).then(|| lang.to_string())
341                        }
342                        CodeBlockKind::Indented => None,
343                    };
344                    *i += 1;
345                    let mut code = String::new();
346                    while *i < events.len() && !matches!(events[*i], Event::End(TagEnd::CodeBlock))
347                    {
348                        if let Event::Text(t) = &events[*i] {
349                            code.push_str(t);
350                        }
351                        *i += 1;
352                    }
353                    consume_end(events, i);
354                    let trimmed = code.trim_end();
355                    // Legacy decodes entities in code; strict keeps it literal.
356                    let text = if self.strict {
357                        trimmed.to_string()
358                    } else {
359                        unescape_entities(trimmed)
360                    };
361                    if !text.is_empty() {
362                        out.push(Node::Code {
363                            language,
364                            text,
365                            orig: None,
366                            pretty: None,
367                        });
368                    }
369                }
370                Event::Start(Tag::Table(_)) => {
371                    *i += 1;
372                    self.parse_table(events, i, out);
373                }
374                _ => {
375                    *i += 1;
376                }
377            }
378        }
379    }
380
381    #[allow(clippy::too_many_arguments)]
382    fn parse_list(
383        &self,
384        events: &[Event],
385        i: &mut usize,
386        out: &mut Vec<Node>,
387        level: u8,
388        ordered: bool,
389        start: u64,
390        depth: u16,
391    ) {
392        let mut number = start;
393        let mut first = true;
394        while *i < events.len() {
395            match &events[*i] {
396                Event::Start(Tag::Item) => {
397                    *i += 1;
398                    self.parse_item(events, i, out, level, ordered, number, first, depth);
399                    number += 1;
400                    first = false;
401                }
402                Event::End(TagEnd::List(_)) => {
403                    *i += 1;
404                    return;
405                }
406                _ => {
407                    *i += 1;
408                }
409            }
410        }
411    }
412
413    #[allow(clippy::too_many_arguments)]
414    fn parse_item(
415        &self,
416        events: &[Event],
417        i: &mut usize,
418        out: &mut Vec<Node>,
419        level: u8,
420        ordered: bool,
421        number: u64,
422        first_in_list: bool,
423        depth: u16,
424    ) {
425        let mut emitted = false;
426        while *i < events.len() {
427            match &events[*i] {
428                Event::End(TagEnd::Item) => {
429                    *i += 1;
430                    break;
431                }
432                Event::Start(Tag::List(_) | Tag::BlockQuote(_)) if depth >= MAX_NESTING => {
433                    skip_subtree(events, i);
434                }
435                Event::Start(Tag::List(start)) => {
436                    let nested_ordered = start.is_some();
437                    let nested_start = start.unwrap_or(1);
438                    *i += 1;
439                    self.parse_list(
440                        events,
441                        i,
442                        out,
443                        level + 1,
444                        nested_ordered,
445                        nested_start,
446                        depth + 1,
447                    );
448                }
449                Event::Start(Tag::Paragraph) => {
450                    *i += 1;
451                    let runs = self.collect_inline(events, i, false);
452                    consume_end(events, i);
453                    emit_item(
454                        out,
455                        &mut emitted,
456                        ordered,
457                        number,
458                        first_in_list,
459                        self.join_runs(&runs, false),
460                        level,
461                    );
462                }
463                ev if is_inline_start(ev) => {
464                    let runs = self.collect_inline(events, i, false);
465                    emit_item(
466                        out,
467                        &mut emitted,
468                        ordered,
469                        number,
470                        first_in_list,
471                        self.join_runs(&runs, false),
472                        level,
473                    );
474                }
475                _ => {
476                    *i += 1;
477                }
478            }
479        }
480    }
481
482    fn parse_table(&self, events: &[Event], i: &mut usize, out: &mut Vec<Node>) {
483        let mut rows: Vec<Vec<String>> = Vec::new();
484        while *i < events.len() {
485            match &events[*i] {
486                Event::Start(Tag::TableHead) | Event::Start(Tag::TableRow) => {
487                    *i += 1;
488                    rows.push(self.parse_table_row(events, i));
489                }
490                Event::End(TagEnd::Table) => {
491                    *i += 1;
492                    break;
493                }
494                _ => {
495                    *i += 1;
496                }
497            }
498        }
499        if !rows.is_empty() {
500            out.push(Node::Table(Table {
501                rows,
502                location: None,
503                structure: None,
504                cell_blocks: None,
505                cells: None,
506                caption: None,
507                caption_parent: Default::default(),
508            }));
509        }
510    }
511
512    fn parse_table_row(&self, events: &[Event], i: &mut usize) -> Vec<String> {
513        let mut cells = Vec::new();
514        while *i < events.len() {
515            match &events[*i] {
516                Event::Start(Tag::TableCell) => {
517                    *i += 1;
518                    let runs = self.collect_inline(events, i, true);
519                    consume_end(events, i);
520                    cells.push(self.join_runs(&runs, true));
521                }
522                Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
523                    *i += 1;
524                    break;
525                }
526                _ => {
527                    *i += 1;
528                }
529            }
530        }
531        cells
532    }
533
534    // -----------------------------------------------------------------------
535    // Inline runs
536    // -----------------------------------------------------------------------
537
538    /// Collect inline runs until a non-inline boundary (left unconsumed). Each
539    /// (already contiguity-merged) text event is its own run; the join then
540    /// separates runs with spaces (legacy) so an escape-split `2\.` becomes
541    /// `2 .` while a contiguous `[11]` stays `[11]`.
542    fn collect_inline(&self, events: &[Event], i: &mut usize, table: bool) -> Vec<String> {
543        let mut runs: Vec<String> = Vec::new();
544        // docling#4019 (2.126): a line break inside a paragraph, heading or
545        // list item is kept. A hard break joins the runs around it with `\n`
546        // (GFM `  \n` once serialized), a soft one with a space; two runs of
547        // the same formatting merge into one item across the break
548        // (`**Bold A**` ⏎ `**Bold B**` → `**Bold A Bold B**`), differently
549        // formatted ones stay separate items, the second carrying the `\n`.
550        let mut pending: Option<Break> = None;
551        while *i < events.len() {
552            match &events[*i] {
553                Event::Text(t) => {
554                    let before = runs.len();
555                    self.push_text(t, table, &mut runs);
556                    if runs.len() > before {
557                        self.apply_break(&mut runs, pending.take(), table);
558                    }
559                    *i += 1;
560                }
561                Event::SoftBreak | Event::HardBreak => {
562                    let hard = matches!(events[*i], Event::HardBreak);
563                    if table {
564                        // A cell is one line: docling reads the raw row, where
565                        // the break is whitespace (the legacy join's space).
566                    } else if self.strict {
567                        // Strict concatenates, so the separator is explicit.
568                        runs.push(if hard { "\n" } else { " " }.to_string());
569                    } else {
570                        pending = Some(if hard { Break::Hard } else { Break::Soft });
571                    }
572                    *i += 1;
573                }
574                // Raw inline HTML tags are dropped.
575                Event::InlineHtml(_) | Event::Html(_) => {
576                    *i += 1;
577                }
578                Event::Code(t) => {
579                    // Strict (non-table) keeps the code span literal; otherwise
580                    // entities are decoded as docling does.
581                    let content = if self.strict && !table {
582                        t.to_string()
583                    } else {
584                        unescape_entities(t)
585                    };
586                    runs.push(if table {
587                        content
588                    } else {
589                        format!("`{content}`")
590                    });
591                    *i += 1;
592                }
593                Event::Start(Tag::Emphasis) => {
594                    runs.push(self.wrap_inline(events, i, table, "*"));
595                    self.apply_break(&mut runs, pending.take(), table);
596                }
597                Event::Start(Tag::Strong) => {
598                    runs.push(self.wrap_inline(events, i, table, "**"));
599                    self.apply_break(&mut runs, pending.take(), table);
600                }
601                Event::Start(Tag::Strikethrough) => {
602                    runs.push(self.wrap_inline(events, i, table, "~~"));
603                    self.apply_break(&mut runs, pending.take(), table);
604                }
605                Event::Start(Tag::Link { dest_url, .. }) => {
606                    let url = dest_url.to_string();
607                    *i += 1;
608                    let inner = self.join_runs(&self.collect_inline(events, i, table), table);
609                    consume_end(events, i);
610                    runs.push(if table {
611                        inner
612                    } else {
613                        format!("[{inner}]({url})")
614                    });
615                }
616                Event::Start(Tag::Image { dest_url, .. }) => {
617                    let url = dest_url.to_string();
618                    *i += 1;
619                    let alt = self.join_runs(&self.collect_inline(events, i, table), table);
620                    consume_end(events, i);
621                    runs.push(if table {
622                        alt
623                    } else {
624                        format!("![{alt}]({url})")
625                    });
626                }
627                _ => break,
628            }
629        }
630        runs
631    }
632
633    /// Resolve a pending line break once the run after it has been pushed
634    /// (legacy, non-table): the last two runs merge when they share their
635    /// formatting — both plain, or both wrapped in the same marker — with
636    /// `\n` (hard) or a space (soft) between their texts; otherwise a hard
637    /// break prefixes the new run with `\n` and a soft one is the join's space.
638    fn apply_break(&self, runs: &mut Vec<String>, pending: Option<Break>, table: bool) {
639        let Some(brk) = pending else {
640            return;
641        };
642        if table || self.strict || runs.len() < 2 {
643            return;
644        }
645        let new = runs.pop().expect("two runs");
646        let prev = runs.pop().expect("two runs");
647        let sep = match brk {
648            Break::Hard => "\n",
649            Break::Soft => " ",
650        };
651        let marker_of = |r: &str| -> Option<&'static str> {
652            ["**", "~~", "*"]
653                .into_iter()
654                .find(|m| r.len() > 2 * m.len() && r.starts_with(m) && r.ends_with(m))
655        };
656        let plain = |r: &str| !r.starts_with(['*', '~', '`', '[', '!']);
657        match (marker_of(&prev), marker_of(&new)) {
658            (None, None) if plain(&prev) && plain(&new) => {
659                runs.push(format!("{prev}{sep}{new}"));
660            }
661            (Some(m), Some(n)) if m == n => {
662                let inner_prev = &prev[m.len()..prev.len() - m.len()];
663                let inner_new = &new[m.len()..new.len() - m.len()];
664                runs.push(format!("{m}{inner_prev}{sep}{inner_new}{m}"));
665            }
666            _ => {
667                runs.push(prev);
668                runs.push(match brk {
669                    Break::Hard => format!("\n{new}"),
670                    Break::Soft => new,
671                });
672            }
673        }
674    }
675
676    fn wrap_inline(&self, events: &[Event], i: &mut usize, table: bool, marker: &str) -> String {
677        *i += 1;
678        let inner = self.join_runs(&self.collect_inline(events, i, table), table);
679        consume_end(events, i);
680        if table {
681            inner
682        } else {
683            format!("{marker}{inner}{marker}")
684        }
685    }
686
687    /// Push one text event as a run. Legacy (non-table) trims and escapes
688    /// `_`/`&<>`; strict keeps it verbatim; table cells are trimmed plain text
689    /// (the serializer escapes pipes).
690    fn push_text(&self, text: &str, table: bool, runs: &mut Vec<String>) {
691        if table {
692            let trimmed = text.trim();
693            if !trimmed.is_empty() {
694                runs.push(trimmed.to_string());
695            }
696        } else if self.strict {
697            if !text.is_empty() {
698                runs.push(text.to_string());
699            }
700        } else {
701            let trimmed = text.trim();
702            if !trimmed.is_empty() {
703                runs.push(escape_html(&escape_underscores(trimmed)));
704            }
705        }
706    }
707
708    /// Join runs. Legacy (and all table cells) drops empty runs and separates
709    /// with single spaces; strict (non-table) concatenates verbatim, preserving
710    /// the source spacing carried in the text runs.
711    fn join_runs(&self, runs: &[String], table: bool) -> String {
712        if self.strict && !table {
713            runs.concat()
714        } else {
715            runs.iter()
716                .filter(|r| !r.is_empty())
717                .cloned()
718                .collect::<Vec<_>>()
719                .join(" ")
720        }
721    }
722}
723
724/// Emit a list item once, only if it has text. docling drops empty items.
725#[allow(clippy::too_many_arguments)]
726fn emit_item(
727    out: &mut Vec<Node>,
728    emitted: &mut bool,
729    ordered: bool,
730    number: u64,
731    first_in_list: bool,
732    text: String,
733    level: u8,
734) {
735    if !*emitted && !text.is_empty() {
736        out.push(Node::ListItem {
737            ordered,
738            number,
739            first_in_list,
740            text,
741            level,
742            marker: None,
743            location: None,
744            dclx: None,
745            href: None,
746            layer: None,
747        });
748        *emitted = true;
749    }
750}
751
752fn consume_end(events: &[Event], i: &mut usize) {
753    if matches!(events.get(*i), Some(Event::End(_))) {
754        *i += 1;
755    }
756}
757
758/// A line break waiting for the run that follows it (see `collect_inline`).
759#[derive(Clone, Copy)]
760enum Break {
761    Soft,
762    Hard,
763}
764
765fn is_inline_start(event: &Event) -> bool {
766    matches!(
767        event,
768        Event::Text(_)
769            | Event::Code(_)
770            | Event::SoftBreak
771            | Event::HardBreak
772            | Event::InlineHtml(_)
773            | Event::Start(Tag::Emphasis | Tag::Strong | Tag::Strikethrough)
774            | Event::Start(Tag::Link { .. } | Tag::Image { .. })
775    )
776}
777
778fn heading_level(level: HeadingLevel) -> u8 {
779    match level {
780        HeadingLevel::H1 => 1,
781        HeadingLevel::H2 => 2,
782        HeadingLevel::H3 => 3,
783        HeadingLevel::H4 => 4,
784        HeadingLevel::H5 => 5,
785        HeadingLevel::H6 => 6,
786    }
787}
788
789// ---------------------------------------------------------------------------
790// Escaping helpers (mirror docling-core's serializer)
791// ---------------------------------------------------------------------------
792
793/// `html.escape(text, quote=False)`: only `& < >`.
794pub(crate) fn escape_html(text: &str) -> String {
795    text.replace('&', "&amp;")
796        .replace('<', "&lt;")
797        .replace('>', "&gt;")
798}
799
800/// docling-core's plain-text escape: underscores then `& < >` (the same pair
801/// `serialize_run` applies). Used by backends that emit text nodes directly.
802pub(crate) fn escape_text(text: &str) -> String {
803    escape_html(&escape_underscores(text))
804}
805
806/// Escape `_` as `\_` unless already escaped.
807pub(crate) fn escape_underscores(text: &str) -> String {
808    let mut out = String::with_capacity(text.len());
809    let mut prev = '\0';
810    for ch in text.chars() {
811        if ch == '_' && prev != '\\' {
812            out.push('\\');
813        }
814        out.push(ch);
815        prev = ch;
816    }
817    out
818}
819
820/// Decode the HTML entities docling's `html.unescape` resolves for the cases we
821/// see (named common + numeric). Used for code spans/blocks, which pulldown
822/// leaves literal but docling decodes.
823pub(super) fn unescape_entities(text: &str) -> String {
824    let mut out = String::with_capacity(text.len());
825    let bytes = text.as_bytes();
826    let mut idx = 0;
827    while idx < bytes.len() {
828        if bytes[idx] == b'&' {
829            if let Some(semi) = text[idx..].find(';') {
830                let entity = &text[idx + 1..idx + semi];
831                if let Some(ch) = decode_entity(entity) {
832                    out.push(ch);
833                    idx += semi + 1;
834                    continue;
835                }
836            }
837        }
838        let ch = text[idx..].chars().next().unwrap();
839        out.push(ch);
840        idx += ch.len_utf8();
841    }
842    out
843}
844
845fn decode_entity(entity: &str) -> Option<char> {
846    match entity {
847        "amp" => Some('&'),
848        "lt" => Some('<'),
849        "gt" => Some('>'),
850        "quot" => Some('"'),
851        "apos" | "#39" => Some('\''),
852        "vert" => Some('|'),
853        _ => {
854            let code = if let Some(hex) = entity.strip_prefix("#x").or(entity.strip_prefix("#X")) {
855                u32::from_str_radix(hex, 16).ok()?
856            } else if let Some(dec) = entity.strip_prefix('#') {
857                dec.parse().ok()?
858            } else {
859                return None;
860            };
861            char::from_u32(code)
862        }
863    }
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869    use crate::format::InputFormat;
870
871    /// docling#4318: the space around inline emphasis inside a table cell
872    /// belongs to the cell (`**C** Cadre` is `C Cadre`, not `CCadre`).
873    #[test]
874    fn table_cells_keep_the_space_around_inline_emphasis() {
875        let doc = convert(
876            "| H | I |\n|---|---|\n| **C** Cadre | x |\n| foo **bar** | y |\n| **A** **B** | z |\n| *italic* and **bold** | w |\n",
877        );
878        let json: serde_json::Value = serde_json::from_str(&doc.export_to_json()).unwrap();
879        let cells: Vec<String> = json["tables"][0]["data"]["table_cells"]
880            .as_array()
881            .unwrap()
882            .iter()
883            .map(|c| c["text"].as_str().unwrap().to_string())
884            .collect();
885        assert_eq!(
886            cells,
887            [
888                "H",
889                "I",
890                "C Cadre",
891                "x",
892                "foo bar",
893                "y",
894                "A B",
895                "z",
896                "italic and bold",
897                "w"
898            ]
899        );
900    }
901
902    fn convert(md: &str) -> DoclingDocument {
903        let src = SourceDocument::from_bytes("t", InputFormat::Md, md.as_bytes().to_vec());
904        MarkdownBackend { strict: false }.convert(&src).unwrap()
905    }
906
907    fn convert_strict(md: &str) -> DoclingDocument {
908        let src = SourceDocument::from_bytes("t", InputFormat::Md, md.as_bytes().to_vec());
909        let mut doc = MarkdownBackend { strict: true }.convert(&src).unwrap();
910        doc.strict_markdown = true;
911        doc
912    }
913
914    /// Block nesting is capped at markdown-it's `maxNesting` (100): 50 000
915    /// `>` or a 5 000-level list used to recurse the parser off the stack.
916    /// The document still converts, with the content past the cap dropped.
917    #[test]
918    fn pathological_nesting_converts_instead_of_overflowing() {
919        let quotes = ">".repeat(50_000) + " deep\n\nafter\n";
920        let md = convert(&quotes).export_to_markdown();
921        assert!(md.contains("after"), "{md:?}");
922        let list: String = (0..5_000)
923            .map(|i| format!("{}- item {i}\n", "  ".repeat(i)))
924            .collect();
925        let md = convert(&list).export_to_markdown();
926        assert!(md.contains("item 0") && md.contains("item 99"), "{md:?}");
927        // Within the cap every level is kept.
928        let shallow: String = (0..20)
929            .map(|i| format!("{}- item {i}\n", "  ".repeat(i)))
930            .collect();
931        let md = convert(&shallow).export_to_markdown();
932        assert!(md.contains("item 19"), "{md:?}");
933    }
934
935    #[test]
936    fn inline_runs_get_spaced_in_legacy() {
937        let doc = convert("Foo *emphasis* **strong** ***both***.\n");
938        assert_eq!(
939            doc.export_to_markdown(),
940            "Foo *emphasis* **strong** ***both*** .\n"
941        );
942    }
943
944    #[test]
945    fn strict_keeps_inline_clean() {
946        let doc = convert_strict("Foo *emphasis* **strong** ***both***.\n");
947        assert_eq!(
948            doc.export_to_markdown(),
949            "Foo *emphasis* **strong** ***both***.\n"
950        );
951    }
952
953    #[test]
954    fn strict_keeps_code_language_and_inline_code() {
955        let doc = convert_strict("```rust\nlet x = 1;\n```\n");
956        assert_eq!(doc.export_to_markdown(), "```rust\nlet x = 1;\n```\n");
957    }
958
959    #[test]
960    fn ordered_list_honors_start() {
961        let doc = convert("3. third\n4. fourth\n");
962        assert_eq!(doc.export_to_markdown(), "3. third\n4. fourth\n");
963    }
964
965    #[test]
966    fn parses_github_table_plain_cells() {
967        let doc = convert("| **A** | B |\n|---|---|\n| x | y |\n");
968        assert_eq!(
969            doc.export_to_markdown(),
970            "| A   | B   |\n|-----|-----|\n| x   | y   |\n"
971        );
972    }
973
974    #[test]
975    fn lone_code_span_becomes_code_block_in_legacy() {
976        let doc = convert("`&amp; &lt;`\n");
977        assert_eq!(
978            doc.nodes,
979            vec![Node::Code {
980                language: None,
981                text: "& <".into(),
982                orig: None,
983                pretty: None,
984            }]
985        );
986    }
987}