Skip to main content

docling_core/
doclang.rs

1//! DocLang XML serialization (`export_to_doclang`) — the markup inside a
2//! `.dclx` archive, mirroring docling-core's `DocLangDocSerializer` with
3//! `DocLangParams()` defaults (version 0.7, 2-space pretty indent, AUTO
4//! CDATA/content wrapping, placeholder image mode → no `<src>` data).
5//!
6//! The Python reference builds a minified string, round-trips it through
7//! `xml.dom.minidom.toprettyxml`, filters empty lines and re-expands
8//! self-closing forms of non-self-closing tags. For the subset our `Node`
9//! model produces, that pipeline's output is reproduced *directly*: an
10//! element whose content is a single text/CDATA run renders inline
11//! (`<text>abc</text>`), anything with element children renders as an
12//! indented block. See docs in the .dclx conformance PR for the full spec.
13//!
14//! Inline formatting: our model bakes bold/italic/code/links into the text as
15//! docling-legacy Markdown markers; [`inline_runs`] re-parses those into the
16//! structural `<bold>`/`<italic>`/`<code>` elements DocLang expects.
17
18use crate::document::{ContentLayer, FieldItem, InlineRun, Node, Script, Table};
19use std::borrow::Cow;
20
21const INDENT: &str = "  ";
22
23/// Rendered fragments: (indent depth, content, newline-after). minidom writes
24/// a CDATA child with no indent and no trailing newline, so the next fragment
25/// (usually the parent's closing tag, at its own indent) lands on the same
26/// line — `newline` false reproduces that glue.
27struct Out {
28    lines: Vec<(i32, String, bool)>,
29    /// Running index for exported image assets (`assets/image_{NNNNNN}_…`),
30    /// incremented per image-bearing picture in document order.
31    pic_index: usize,
32}
33
34impl Out {
35    fn push(&mut self, depth: i32, s: impl Into<String>) {
36        self.lines.push((depth, s.into(), true));
37    }
38
39    /// A fragment with no indent and no trailing newline (CDATA glue).
40    fn push_glue(&mut self, s: impl Into<String>) {
41        self.lines.push((0, s.into(), false));
42    }
43
44    fn finish(self) -> String {
45        let mut s = String::new();
46        for (d, line, nl) in self.lines {
47            // minidom writes every node's indentation prefix; only a glued
48            // fragment (CDATA/plain text child) suppresses the *newline*, so
49            // the following node's indent lands on the same line. Emitting the
50            // indent unconditionally reproduces that (glue fragments carry
51            // depth 0, contributing none).
52            for _ in 0..d {
53                s.push_str(INDENT);
54            }
55            s.push_str(&line);
56            if nl {
57                s.push('\n');
58            }
59        }
60        // The reference's empty-line filter drops the trailing blank, so the
61        // serialized text carries no final newline; the archive writer adds
62        // exactly one back.
63        if s.ends_with('\n') {
64            s.pop();
65        }
66        s
67    }
68}
69
70/// Reverse the Markdown-oriented escaping backends bake into node text
71/// (`&amp;`/`&lt;`/`&gt;` and `\_`), recovering the raw text DocLang serializes:
72/// `<`/`>`/`&` go into a CDATA section verbatim, `_` stays literal.
73fn unescape_stored(text: &str) -> Cow<'_, str> {
74    if !text.contains('&') && !text.contains('\\') {
75        return Cow::Borrowed(text);
76    }
77    Cow::Owned(
78        text.replace("&lt;", "<")
79            .replace("&gt;", ">")
80            .replace("&amp;", "&")
81            .replace("\\_", "_"),
82    )
83}
84
85/// AUTO escape: any of `"' &<>` in the text → CDATA; leading/trailing
86/// whitespace or a newline → additionally wrapped in `<content>`.
87fn escape_text(text: &str) -> String {
88    let raw = unescape_stored(text);
89    let text = raw.as_ref();
90    let needs_cdata = text.contains(['"', '\'', '&', '<', '>']);
91    let needs_content = text != text.trim() || text.contains('\n');
92    let mut t = if needs_cdata {
93        format!("<![CDATA[{text}]]>")
94    } else {
95        text.to_string()
96    };
97    if needs_content {
98        t = format!("<content>{t}</content>");
99    }
100    t
101}
102
103/// An inline run of a text node after re-parsing our Markdown markers.
104enum Run {
105    Plain(String),
106    Bold(String),
107    Italic(String),
108    BoldItalic(String),
109    Code(String),
110    /// `[anchor](uri)` — DocLang has no inline href element; the anchor text
111    /// stays inline and, when the run is the only content, the uri becomes a
112    /// `<href uri=…/>` in the element head.
113    Link {
114        anchor: String,
115        uri: String,
116    },
117}
118
119/// Split docling-legacy inline markers (`***x***`, `**x**`, `*x*`, `` `x` ``,
120/// `[t](u)`) into runs. Unmatched markers stay literal.
121fn inline_runs(text: &str) -> Vec<Run> {
122    let mut runs = Vec::new();
123    let mut plain = String::new();
124    let chars: Vec<char> = text.chars().collect();
125    let n = chars.len();
126    let mut i = 0;
127    // All scanning stays on the char slice: materializing the tail as a
128    // String per position (the previous shape) made this scanner O(n²) and
129    // dominated whole-document DocLang serialization.
130    let find = |open: usize, pat: &[char]| -> Option<usize> {
131        chars
132            .get(open..)?
133            .windows(pat.len())
134            .position(|w| w == pat)
135            .map(|p| open + p)
136    };
137    let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
138    while i < n {
139        let take = |runs: &mut Vec<Run>, plain: &mut String, r: Run| {
140            if !plain.is_empty() {
141                runs.push(Run::Plain(std::mem::take(plain)));
142            }
143            runs.push(r);
144        };
145        if starts(i, &['*', '*', '*']) {
146            if let Some(end) = find(i + 3, &['*', '*', '*']) {
147                let inner: String = chars[i + 3..end].iter().collect();
148                take(&mut runs, &mut plain, Run::BoldItalic(inner));
149                i = end + 3;
150                continue;
151            }
152        }
153        if starts(i, &['*', '*']) {
154            if let Some(end) = find(i + 2, &['*', '*']) {
155                let inner: String = chars[i + 2..end].iter().collect();
156                take(&mut runs, &mut plain, Run::Bold(inner));
157                i = end + 2;
158                continue;
159            }
160        }
161        if chars[i] == '*' && !starts(i, &['*', '*']) {
162            if let Some(end) = find(i + 1, &['*']) {
163                let inner: String = chars[i + 1..end].iter().collect();
164                if !inner.is_empty() {
165                    take(&mut runs, &mut plain, Run::Italic(inner));
166                    i = end + 1;
167                    continue;
168                }
169            }
170        }
171        if chars[i] == '`' {
172            if let Some(end) = find(i + 1, &['`']) {
173                let inner: String = chars[i + 1..end].iter().collect();
174                take(&mut runs, &mut plain, Run::Code(inner));
175                i = end + 1;
176                continue;
177            }
178        }
179        if chars[i] == '[' {
180            if let Some(close) = find(i + 1, &[']', '(']) {
181                if let Some(endp) = find(close + 2, &[')']) {
182                    let anchor: String = chars[i + 1..close].iter().collect();
183                    let uri: String = chars[close + 2..endp].iter().collect();
184                    take(&mut runs, &mut plain, Run::Link { anchor, uri });
185                    i = endp + 1;
186                    continue;
187                }
188            }
189        }
190        plain.push(chars[i]);
191        i += 1;
192    }
193    if !plain.is_empty() {
194        runs.push(Run::Plain(plain));
195    }
196    runs
197}
198
199/// Parse a docling-legacy Markdown string into structured [`InlineRun`]s for a
200/// [`Node::InlineGroup`]. Handles the marker set docling emits — `***`, `**`,
201/// `*`, `~~`, `` ` ``, `[t](u)` — recursively so nested markers combine
202/// formatting, and splits plain text on newlines (docling's `<br>` / text-node
203/// boundaries become separate runs). Underline and sub/superscript have no
204/// Markdown representation and therefore never appear via this path.
205pub fn inline_runs_from_markdown(text: &str) -> Vec<InlineRun> {
206    let mut out = Vec::new();
207    parse_md_runs(
208        &text.chars().collect::<Vec<_>>(),
209        InlineRun::default(),
210        &mut out,
211    );
212    out
213}
214
215/// Flush `acc`'s buffered text into `out` as one run, trimmed; a blank segment
216/// yields nothing (docling has no empty text items). Internal newlines (soft
217/// breaks) are kept — docling holds them in a single text item.
218fn flush_md_plain(buf: &mut String, style: &InlineRun, out: &mut Vec<InlineRun>) {
219    let text = std::mem::take(buf);
220    let text = text.trim();
221    if !text.is_empty() {
222        out.push(InlineRun {
223            text: text.to_string(),
224            ..style.clone()
225        });
226    }
227}
228
229/// Recursive marker scanner: `style` carries the formatting active from enclosing
230/// spans; plain text inherits it, and each marker recurses with the extra flag.
231fn parse_md_runs(chars: &[char], style: InlineRun, out: &mut Vec<InlineRun>) {
232    let n = chars.len();
233    let mut i = 0;
234    let mut plain = String::new();
235    // Char-slice scanning throughout — the tail-String-per-position shape
236    // this replaces was O(n²) over every serialized text node.
237    let find = |open: usize, pat: &[char]| -> Option<usize> {
238        chars
239            .get(open..)?
240            .windows(pat.len())
241            .position(|w| w == pat)
242            .map(|p| open + p)
243    };
244    let starts = |at: usize, pat: &[char]| chars[at..].starts_with(pat);
245    let sub = |a: usize, b: usize| -> Vec<char> { chars[a..b].to_vec() };
246    while i < n {
247        // Longest markers first so `**`/`***` aren't mis-split.
248        if starts(i, &['*', '*', '*']) {
249            if let Some(end) = find(i + 3, &['*', '*', '*']) {
250                flush_md_plain(&mut plain, &style, out);
251                parse_md_runs(
252                    &sub(i + 3, end),
253                    InlineRun {
254                        bold: true,
255                        italic: true,
256                        ..style.clone()
257                    },
258                    out,
259                );
260                i = end + 3;
261                continue;
262            }
263        }
264        if starts(i, &['*', '*']) {
265            if let Some(end) = find(i + 2, &['*', '*']) {
266                flush_md_plain(&mut plain, &style, out);
267                parse_md_runs(
268                    &sub(i + 2, end),
269                    InlineRun {
270                        bold: true,
271                        ..style.clone()
272                    },
273                    out,
274                );
275                i = end + 2;
276                continue;
277            }
278        }
279        if chars[i] == '*' {
280            if let Some(end) = find(i + 1, &['*']) {
281                if end > i + 1 {
282                    flush_md_plain(&mut plain, &style, out);
283                    parse_md_runs(
284                        &sub(i + 1, end),
285                        InlineRun {
286                            italic: true,
287                            ..style.clone()
288                        },
289                        out,
290                    );
291                    i = end + 1;
292                    continue;
293                }
294            }
295        }
296        if starts(i, &['~', '~']) {
297            if let Some(end) = find(i + 2, &['~', '~']) {
298                flush_md_plain(&mut plain, &style, out);
299                parse_md_runs(
300                    &sub(i + 2, end),
301                    InlineRun {
302                        strike: true,
303                        ..style.clone()
304                    },
305                    out,
306                );
307                i = end + 2;
308                continue;
309            }
310        }
311        if chars[i] == '`' {
312            if let Some(end) = find(i + 1, &['`']) {
313                flush_md_plain(&mut plain, &style, out);
314                let inner: String = sub(i + 1, end).iter().collect();
315                let inner = inner.trim();
316                if !inner.is_empty() {
317                    out.push(InlineRun {
318                        text: inner.to_string(),
319                        code: true,
320                        ..style.clone()
321                    });
322                }
323                i = end + 1;
324                continue;
325            }
326        }
327        if chars[i] == '[' {
328            if let Some(close) = find(i + 1, &[']', '(']) {
329                if let Some(endp) = find(close + 2, &[')']) {
330                    flush_md_plain(&mut plain, &style, out);
331                    // Inline scope drops the href; the anchor keeps its styling.
332                    parse_md_runs(&sub(i + 1, close), style.clone(), out);
333                    i = endp + 1;
334                    continue;
335                }
336            }
337        }
338        plain.push(chars[i]);
339        i += 1;
340    }
341    flush_md_plain(&mut plain, &style, out);
342}
343
344/// Attribute-value escaping for generated URIs/labels.
345fn attr_escape(v: &str) -> String {
346    v.replace('&', "&amp;").replace('"', "&quot;")
347}
348
349/// Render a text body (with inline markers) into `out`.
350///
351/// A single plain run renders inline within its wrapper; mixed runs become
352/// the reference's block form: plain fragments as bare indented lines,
353/// formatted fragments as their own inline elements — matching minidom's
354/// output for a `<text>` with element children.
355fn emit_text_element(
356    out: &mut Out,
357    depth: i32,
358    tag_open: &str,
359    tag: &str,
360    text: &str,
361    location: Option<&[u16; 4]>,
362) {
363    // With layout provenance the element renders in block form: the `<location>`
364    // tokens are element children, then the text runs.
365    if let Some(loc) = location {
366        out.push(depth, format!("<{tag_open}>"));
367        push_location(out, depth + 1, loc);
368        if !text.is_empty() {
369            emit_runs(out, depth + 1, inline_runs(text));
370        }
371        out.push(depth, format!("</{tag}>"));
372        return;
373    }
374    // An empty text item renders as an empty element on one line (docling emits
375    // one per blank body paragraph).
376    if text.is_empty() {
377        out.push(depth, format!("<{tag_open}></{tag}>"));
378        return;
379    }
380    let runs = inline_runs(text);
381    let only_plain = runs.len() == 1 && matches!(runs[0], Run::Plain(_));
382    // A lone `[anchor](uri)` becomes `<href uri=…/>` in the head; the anchor's
383    // own markers still render (`[***x***](u)` → href + `<italic><bold>…`).
384    if runs.len() == 1 {
385        if let Run::Link { anchor, uri } = &runs[0] {
386            out.push(depth, format!("<{tag_open}>"));
387            out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(uri)));
388            if !anchor.trim().is_empty() {
389                emit_runs(out, depth + 1, inline_runs(anchor));
390            }
391            out.push(depth, format!("</{tag}>"));
392            return;
393        }
394    }
395    if only_plain {
396        let body = escape_text(text);
397        // A `<content>` wrapper is an *element* child, so minidom renders the
398        // wrapper in block form; bare text / CDATA is a single text child and
399        // stays inline.
400        if body.starts_with("<content>") {
401            out.push(depth, format!("<{tag_open}>"));
402            out.push(depth + 1, body);
403            out.push(depth, format!("</{tag}>"));
404        } else {
405            out.push(depth, format!("<{tag_open}>{body}</{tag}>"));
406        }
407        return;
408    }
409    out.push(depth, format!("<{tag_open}>"));
410    emit_runs(out, depth + 1, runs);
411    out.push(depth, format!("</{tag}>"));
412}
413
414fn emit_runs(out: &mut Out, depth: i32, runs: Vec<Run>) {
415    for run in runs {
416        match run {
417            Run::Plain(t) => {
418                let t = t.trim_matches('\n');
419                if !t.is_empty() {
420                    emit_text_node(out, depth, t);
421                }
422            }
423            Run::Bold(t) => out.push(depth, format!("<bold>{}</bold>", escape_text(&t))),
424            Run::Italic(t) => out.push(depth, format!("<italic>{}</italic>", escape_text(&t))),
425            Run::BoldItalic(t) => {
426                out.push(depth, "<italic>".to_string());
427                out.push(depth + 1, format!("<bold>{}</bold>", escape_text(&t)));
428                out.push(depth, "</italic>".to_string());
429            }
430            Run::Code(t) => out.push(depth, format!("<code>{}</code>", escape_text(&t))),
431            Run::Link { anchor, .. } => {
432                // Inline scope: DocLang drops the target, keeps the anchor.
433                if !anchor.is_empty() {
434                    emit_text_node(out, depth, &anchor);
435                }
436            }
437        }
438    }
439}
440
441/// A text node in block (element-children) context: plain data indents like
442/// any child; a `<content>` wrapper is a normal element; bare CDATA glues to
443/// the next fragment with no indent/newline (minidom's CDATA rule).
444fn emit_text_node(out: &mut Out, depth: i32, text: &str) {
445    let e = escape_text(text);
446    if e.starts_with("<![CDATA[") {
447        out.push_glue(e);
448    } else {
449        out.push(depth, e);
450    }
451}
452
453/// Map a docling `CodeLanguageLabel` value (as stored in [`Node::Code::language`]
454/// and the JSON export) to the DocLang recommended (Linguist) label. Returns
455/// `None` for unknown/absent languages — matching docling's AUTO `label_mode`,
456/// which omits the `<label>` when the resolved label would be `undefined`.
457fn code_lang_label(lang: &str) -> Option<&'static str> {
458    // Fold the raw fence string (e.g. "python") onto the canonical docling
459    // `CodeLanguageLabel` value ("Python") first — the same normalization the
460    // JSON export uses — then map that to the DocLang (Linguist) label.
461    let lang = crate::json::code_language(Some(lang));
462    Some(match lang {
463        // Docling values whose Linguist key differs.
464        "Bash" => "Shell",
465        "FORTRAN" => "Fortran",
466        "Latex" => "TeX",
467        "Lisp" => "Common Lisp",
468        "Matlab" | "Octave" => "MATLAB",
469        "ObjectiveC" => "Objective-C",
470        "SML" => "Standard ML",
471        "VisualBasic" => "Visual Basic .NET",
472        "DocLang" => "XML",
473        // Docling labels without a distinct Linguist key collapse to `other`.
474        "bc" | "dc" | "Tikz" => "other",
475        // Values whose Linguist key equals the docling value.
476        "Ada" | "Awk" | "C" | "C#" | "C++" | "CMake" | "COBOL" | "CSS" | "Ceylon" | "Clojure"
477        | "Crystal" | "Cuda" | "Cython" | "D" | "Dart" | "Dockerfile" | "Elixir" | "Erlang"
478        | "Forth" | "Go" | "HTML" | "Haskell" | "Haxe" | "Java" | "JavaScript" | "JSON"
479        | "Julia" | "Kotlin" | "Lua" | "MoonScript" | "Nim" | "OCaml" | "PHP" | "Pascal"
480        | "Perl" | "Prolog" | "Python" | "Racket" | "Ruby" | "Rust" | "SQL" | "Scala"
481        | "Scheme" | "Swift" | "TypeScript" | "XML" | "YAML" => {
482            return Some(IDENTITY_LABELS[IDENTITY_LABELS.iter().position(|&x| x == lang).unwrap()])
483        }
484        _ => return None, // "unknown" and anything unrecognized → no <label>
485    })
486}
487
488/// Language labels whose DocLang (Linguist) form is identical to the docling
489/// `CodeLanguageLabel` value — used to hand back a `'static` reference.
490static IDENTITY_LABELS: &[&str] = &[
491    "Ada",
492    "Awk",
493    "C",
494    "C#",
495    "C++",
496    "CMake",
497    "COBOL",
498    "CSS",
499    "Ceylon",
500    "Clojure",
501    "Crystal",
502    "Cuda",
503    "Cython",
504    "D",
505    "Dart",
506    "Dockerfile",
507    "Elixir",
508    "Erlang",
509    "Forth",
510    "Go",
511    "HTML",
512    "Haskell",
513    "Haxe",
514    "Java",
515    "JavaScript",
516    "JSON",
517    "Julia",
518    "Kotlin",
519    "Lua",
520    "MoonScript",
521    "Nim",
522    "OCaml",
523    "PHP",
524    "Pascal",
525    "Perl",
526    "Prolog",
527    "Python",
528    "Racket",
529    "Ruby",
530    "Rust",
531    "SQL",
532    "Scala",
533    "Scheme",
534    "Swift",
535    "TypeScript",
536    "XML",
537    "YAML",
538];
539
540/// Emit a `<code>` element. With a resolved language, a `<label value=…/>` head
541/// forces the block form (matching docling); the code text follows as a text
542/// child (CDATA/plain glued to the closing tag, `<content>`-wrapped text on its
543/// own line). Without a language, single-fragment text renders inline.
544fn emit_code(
545    out: &mut Out,
546    depth: i32,
547    language: Option<&str>,
548    text: &str,
549    location: Option<&[u16; 4]>,
550) {
551    let label = language.and_then(code_lang_label);
552    let escaped = escape_text(text);
553    let is_content_element = escaped.starts_with("<content>");
554    // Layout provenance forces the block form: `<location>` tokens follow the
555    // opening `<code>`, before the (optional) label and the code body.
556    if let Some(loc) = location {
557        out.push(depth, "<code>".to_string());
558        push_location(out, depth + 1, loc);
559        if let Some(l) = label {
560            out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
561        }
562        if is_content_element {
563            out.push(depth + 1, escaped);
564        } else {
565            out.push_glue(escaped);
566        }
567        out.push(depth, "</code>".to_string());
568        return;
569    }
570    match (label, is_content_element) {
571        (None, false) => out.push(depth, format!("<code>{escaped}</code>")),
572        (None, true) => {
573            out.push(depth, "<code>".to_string());
574            out.push(depth + 1, escaped);
575            out.push(depth, "</code>".to_string());
576        }
577        (Some(l), false) => {
578            out.push(depth, "<code>".to_string());
579            out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
580            // Text child glues at column 0; the closing tag keeps its indent.
581            out.push_glue(escaped);
582            out.push(depth, "</code>".to_string());
583        }
584        (Some(l), true) => {
585            out.push(depth, "<code>".to_string());
586            out.push(depth + 1, format!("<label value=\"{}\"/>", attr_escape(l)));
587            out.push(depth + 1, escaped);
588            out.push(depth, "</code>".to_string());
589        }
590    }
591}
592
593/// Emit the four `<location>` provenance tokens (`x0,y0,x1,y1`) as element
594/// children — docling's element head for backends with real geometry.
595fn push_location(out: &mut Out, depth: i32, loc: &[u16; 4]) {
596    for v in loc {
597        out.push(depth, format!("<location value=\"{v}\"/>"));
598    }
599}
600
601fn emit_table(out: &mut Out, depth: i32, table: &Table) {
602    out.push(depth, "<table>".to_string());
603    if let Some(cap) = &table.caption {
604        // `caption` arrives already escaped (backend convention), so it is
605        // emitted verbatim as the table's first child.
606        out.push(depth + 1, format!("<caption>{cap}</caption>"));
607    }
608    emit_table_rows(out, depth, table);
609    out.push(depth, "</table>".to_string());
610}
611
612/// A chart — docling's `PictureItem` with a tabular chart-data annotation:
613/// `<picture class="chart">` wrapping a `<label value="{kind}"/>` and the data
614/// grid as a `<tabular>` (same cell tokens as a table).
615fn emit_chart(
616    out: &mut Out,
617    depth: i32,
618    kind: &str,
619    table: &Table,
620    caption: Option<&str>,
621    location: Option<&[u16; 4]>,
622) {
623    out.pic_index += 1;
624    out.push(depth, "<picture class=\"chart\">".to_string());
625    out.push(
626        depth + 1,
627        format!("<label value=\"{}\"/>", attr_escape(kind)),
628    );
629    if let Some(loc) = location {
630        push_location(out, depth + 1, loc);
631    }
632    if let Some(cap) = caption {
633        out.push(
634            depth + 1,
635            format!("<caption>{}</caption>", escape_text(cap)),
636        );
637    }
638    out.push(depth + 1, "<tabular>".to_string());
639    emit_table_rows(out, depth + 1, table);
640    out.push(depth + 1, "</tabular>".to_string());
641    out.push(depth, "</picture>".to_string());
642}
643
644/// Emit a grid's cells (the shared body of `<table>` and a chart's `<tabular>`):
645/// the location head, then each row's OTSL cell tokens at `depth + 1`.
646fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
647    // Layout provenance (spreadsheet/slide backends): four `<location>` tokens
648    // (x0,y0,x1,y1) precede the cells, matching docling's element head.
649    if let Some(loc) = &table.location {
650        push_location(out, depth + 1, loc);
651    }
652    for (ri, row) in table.rows.iter().enumerate() {
653        for (ci, cell) in row.iter().enumerate() {
654            // A span continuation is a token-only cell (no text child):
655            // horizontal → `<lcel/>`, vertical → `<ucel/>`. Otherwise
656            // empty→`<ecel/>`, header→`<ched/>`, else `<fcel/>`.
657            let cont = |grid: &Vec<Vec<bool>>| {
658                grid.get(ri)
659                    .and_then(|r| r.get(ci))
660                    .copied()
661                    .unwrap_or(false)
662            };
663            let is_lcel = table
664                .structure
665                .as_ref()
666                .map(|s| cont(&s.col_continuation))
667                .unwrap_or(false);
668            let is_ucel = table
669                .structure
670                .as_ref()
671                .map(|s| cont(&s.row_continuation))
672                .unwrap_or(false);
673            let is_header = match &table.structure {
674                Some(s) if !s.col_header.is_empty() => s
675                    .col_header
676                    .get(ri)
677                    .and_then(|r| r.get(ci))
678                    .copied()
679                    .unwrap_or(false),
680                Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
681                None => ri == 0,
682            };
683            let is_row_header = table
684                .structure
685                .as_ref()
686                .map(|s| {
687                    s.row_header
688                        .get(ri)
689                        .and_then(|r| r.get(ci))
690                        .copied()
691                        .unwrap_or(false)
692                })
693                .unwrap_or(false);
694            let tok = if is_lcel && is_ucel {
695                // Continues a span in both axes (a 2-D covered cell) → `<xcel/>`.
696                "<xcel/>"
697            } else if is_lcel {
698                "<lcel/>"
699            } else if is_ucel {
700                "<ucel/>"
701            } else if cell.trim().is_empty() {
702                "<ecel/>"
703            } else if is_header {
704                "<ched/>"
705            } else if is_row_header {
706                "<rhed/>"
707            } else {
708                "<fcel/>"
709            };
710            out.push(depth + 1, tok.to_string());
711            if !is_lcel && !is_ucel {
712                // A rich cell (ODF lists / nested tables / multi-paragraph)
713                // emits its structured blocks after the token; otherwise the
714                // flat cell text renders inline.
715                let blocks = table
716                    .cell_blocks
717                    .as_ref()
718                    .and_then(|b| b.get(ri))
719                    .and_then(|r| r.get(ci))
720                    .filter(|b| !b.is_empty());
721                if let Some(blocks) = blocks {
722                    let mut bi = 0;
723                    emit_nodes(out, depth + 1, blocks, &mut bi, 0);
724                } else if !cell.trim().is_empty() {
725                    emit_cell_text(out, depth + 1, cell);
726                }
727            }
728        }
729        out.push(depth + 1, "<nl/>".to_string());
730    }
731}
732
733/// Table-cell content: virtual text (no wrapper), inline markers re-parsed.
734fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
735    let runs = inline_runs(text.trim());
736    emit_runs(out, depth, runs);
737}
738
739/// Serialize the node stream to DocLang XML (no trailing newline).
740pub fn export_to_doclang(nodes: &[Node]) -> String {
741    let mut out = Out {
742        lines: Vec::new(),
743        pic_index: 0,
744    };
745    out.push(0, "<doclang version=\"0.7\">".to_string());
746    let mut i = 0usize;
747    emit_nodes(&mut out, 1, nodes, &mut i, 0);
748    out.push(0, "</doclang>".to_string());
749    out.finish()
750}
751
752/// Emit nodes at list-nesting `level`; consumes consecutive ListItems into
753/// `<list>` blocks (recursing for deeper levels).
754fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
755    while *i < nodes.len() {
756        match &nodes[*i] {
757            Node::Heading { level, text } => {
758                let open = if *level <= 1 {
759                    "heading".to_string()
760                } else {
761                    format!("heading level=\"{level}\"")
762                };
763                emit_text_element(out, depth, &open, "heading", text, None);
764                *i += 1;
765            }
766            Node::Paragraph { text } => {
767                // A standalone display equation (docling's block `FormulaItem`) is
768                // stored as a `$$…$$` paragraph so Markdown/JSON render the fenced
769                // math; DocLang emits it as a `<formula>` element.
770                if let Some(latex) = text
771                    .strip_prefix("$$")
772                    .and_then(|t| t.strip_suffix("$$"))
773                    .filter(|t| !t.is_empty())
774                {
775                    out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
776                } else {
777                    emit_text_element(out, depth, "text", "text", text, None);
778                }
779                *i += 1;
780            }
781            Node::CheckboxItem { checked, text } => {
782                // A `<text>` with a `<checkbox class="selected|unselected"/>` head
783                // element and the label text child (block form).
784                let class = if *checked { "selected" } else { "unselected" };
785                out.push(depth, "<text>".to_string());
786                out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
787                if !text.is_empty() {
788                    out.push(depth + 1, escape_text(text));
789                }
790                out.push(depth, "</text>".to_string());
791                *i += 1;
792            }
793            Node::Code { language, text, .. } => {
794                emit_code(out, depth, language.as_deref(), text, None);
795                *i += 1;
796            }
797            // A CodeFormula-decoded display formula: a `<formula>` element like
798            // the inline-math one, with the layout location when present.
799            Node::Formula {
800                latex, location, ..
801            } => {
802                if let Some(loc) = location {
803                    out.push(depth, "<formula>".to_string());
804                    push_location(out, depth + 1, loc);
805                    if !latex.is_empty() {
806                        out.push(depth + 1, escape_text(latex));
807                    }
808                    out.push(depth, "</formula>".to_string());
809                } else {
810                    out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
811                }
812                *i += 1;
813            }
814            Node::PageFurniture {
815                footer,
816                location,
817                text,
818            } => {
819                let tag = if *footer {
820                    "page_footer"
821                } else {
822                    "page_header"
823                };
824                out.push(depth, format!("<{tag}>"));
825                out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
826                push_location(out, depth + 1, location);
827                if !text.is_empty() {
828                    out.push(depth + 1, escape_text(text));
829                }
830                out.push(depth, format!("</{tag}>"));
831                *i += 1;
832            }
833            Node::Table(t) => {
834                emit_table(out, depth, t);
835                *i += 1;
836            }
837            // Classifier predictions are JSON-only; DocLang keeps the plain
838            // `<picture>` shape.
839            Node::Picture { caption, image, .. } => {
840                emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
841                *i += 1;
842            }
843            Node::Chart {
844                kind,
845                table,
846                caption,
847                location,
848            } => {
849                emit_chart(
850                    out,
851                    depth,
852                    kind,
853                    table,
854                    caption.as_deref(),
855                    location.as_ref(),
856                );
857                *i += 1;
858            }
859            Node::DoclangOnly(inner) => {
860                let mut j = 0;
861                emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
862                *i += 1;
863            }
864            Node::ListItem { level: l, .. } => {
865                if *l < level {
866                    return; // caller's list continues / closes
867                }
868                emit_list(out, depth, nodes, i, *l);
869            }
870            Node::Group { children, .. } => {
871                let mut j = 0usize;
872                emit_nodes(out, depth, children, &mut j, 0);
873                *i += 1;
874            }
875            Node::FieldRegion { items } => {
876                emit_field_region(out, depth, items);
877                *i += 1;
878            }
879            Node::InlineGroup {
880                unwrapped, runs, ..
881            } => {
882                emit_inline_group(out, depth, *unwrapped, runs);
883                *i += 1;
884            }
885            Node::Furniture { layer, inner } => {
886                emit_furniture(out, depth, *layer, inner);
887                *i += 1;
888            }
889            Node::Located { location, inner } => {
890                emit_located(out, depth, location, inner);
891                *i += 1;
892            }
893            Node::PageBreak => {
894                out.push(depth, "<page_break/>".to_string());
895                *i += 1;
896            }
897            // Page markers feed the JSON export only — DocLang stays unchanged
898            // (its geometry travels in the <location> tokens).
899            Node::PageInfo { .. } => {
900                *i += 1;
901            }
902            Node::TextDump(text) => {
903                emit_text_dump(out, depth, text);
904                *i += 1;
905            }
906        }
907    }
908}
909
910/// One minidom child of the dump's `<text>`: a plain text node, a `<![CDATA[…]]>`
911/// section, or a formatted element (`<italic>…</italic>`).
912enum DumpNode {
913    Text(String),
914    Cdata(String),
915    Elem(String),
916}
917
918/// Render docling's plain-text backend dump: the whole file as one `<text>` item,
919/// serialized the way `xml.dom.minidom.toprettyxml` renders a `<text>` element.
920///
921/// docling applies inline Markdown to the text item, then builds a minified
922/// `<text>…</text>` string — each source line a record, `*`-emphasis converted to
923/// `<italic>`, XML-significant lines (`" ' & < >`) wrapped in `<![CDATA[…]]>` — and
924/// pretty-prints it, dropping blank lines. This reproduces that pipeline: parse the
925/// emphasis ([`dump_records`]), assemble the minidom child nodes, then simulate
926/// `toprettyxml`, which writes a text node as `indent + data`, a CDATA section as a
927/// bare `<![CDATA[…]]>` (no indent, no newline — so the next child's indent glues
928/// onto its line), and an element as `indent + <tag>…</tag>`.
929fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
930    let records = dump_records(text);
931    if records.is_empty() {
932        out.push(depth, "<text></text>".to_string());
933        return;
934    }
935    // Assemble the `<text>` element's minidom children. Consecutive plain records
936    // (and the `\n` record separators around them) collapse into one text node; a
937    // CDATA or formatted record breaks the run into its own node.
938    let mut nodes: Vec<DumpNode> = Vec::new();
939    let mut buf = String::new();
940    for (r, (line, italic)) in records.iter().enumerate() {
941        if r > 0 {
942            buf.push('\n'); // the record separator
943        }
944        let raw = unescape_stored(line);
945        let s = raw.as_ref();
946        let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
947        if *italic || is_cdata {
948            if !buf.is_empty() {
949                nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
950            }
951            let inner = if is_cdata {
952                format!("<![CDATA[{s}]]>")
953            } else {
954                s.to_string()
955            };
956            if *italic {
957                nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
958            } else {
959                nodes.push(DumpNode::Cdata(inner));
960            }
961        } else {
962            buf.push_str(s);
963        }
964    }
965    if !buf.is_empty() {
966        nodes.push(DumpNode::Text(buf));
967    }
968
969    // A lone text node is a single text child — minidom renders it inline.
970    if let [DumpNode::Text(d)] = nodes.as_slice() {
971        out.push(depth, format!("<text>{d}\n</text>"));
972        return;
973    }
974
975    // Simulate `toprettyxml`: element/text children indent at depth+1, CDATA sits
976    // bare; then drop the blank lines docling's empty-line filter removes.
977    let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
978    let ind_self = INDENT.repeat(depth.max(0) as usize);
979    let mut raw = String::new();
980    for node in &nodes {
981        match node {
982            DumpNode::Text(d) => {
983                raw.push_str(&ind_child);
984                raw.push_str(d);
985                raw.push('\n');
986            }
987            DumpNode::Cdata(b) => raw.push_str(b),
988            DumpNode::Elem(b) => {
989                raw.push_str(&ind_child);
990                raw.push_str(b);
991                raw.push('\n');
992            }
993        }
994    }
995    let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
996    for line in full.split('\n') {
997        if !line.trim().is_empty() {
998            out.push(0, line.to_string());
999        }
1000    }
1001}
1002
1003/// Parse a plain-text dump into one record per line, applying docling's inline
1004/// Markdown: CommonMark `*`/`**` emphasis (flanking rules + the delimiter-stack
1005/// match) is stripped and its span flagged `italic`; a Markdown thematic break (a
1006/// line of only underscores) collapses to ten underscores; blank lines drop out.
1007fn dump_records(text: &str) -> Vec<(String, bool)> {
1008    let chars: Vec<char> = text.chars().collect();
1009    let n = chars.len();
1010
1011    struct Delim {
1012        pos: usize,
1013        length: usize,
1014        rem: usize,
1015        can_open: bool,
1016        can_close: bool,
1017    }
1018    let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1019    let is_punct =
1020        |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1021
1022    // Delimiter runs of `*`, each tagged left-/right-flanking (CommonMark 6.2).
1023    let mut delims: Vec<Delim> = Vec::new();
1024    let mut i = 0;
1025    while i < n {
1026        if chars[i] == '*' {
1027            let mut j = i;
1028            while j < n && chars[j] == '*' {
1029                j += 1;
1030            }
1031            let prev = (i > 0).then(|| chars[i - 1]);
1032            let next = (j < n).then(|| chars[j]);
1033            let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1034            let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1035            delims.push(Delim {
1036                pos: i,
1037                length: j - i,
1038                rem: j - i,
1039                can_open: left,
1040                can_close: right,
1041            });
1042            i = j;
1043        } else {
1044            i += 1;
1045        }
1046    }
1047
1048    // Match closers to the nearest eligible opener (CommonMark "process emphasis"),
1049    // marking the delimiter characters consumed and the spanned text emphasized.
1050    let mut emph = vec![false; n];
1051    let mut consumed = vec![false; n];
1052    let mut ci = 0;
1053    while ci < delims.len() {
1054        if !(delims[ci].can_close && delims[ci].rem > 0) {
1055            ci += 1;
1056            continue;
1057        }
1058        let mut found: Option<usize> = None;
1059        let mut oi = ci as i64 - 1;
1060        while oi >= 0 {
1061            let o = &delims[oi as usize];
1062            let c = &delims[ci];
1063            if o.can_open && o.rem > 0 {
1064                // "Rule of three": a run may not close its own kind when the
1065                // combined length is a multiple of three (unless both are).
1066                let odd = (o.can_close || c.can_open)
1067                    && (o.length + c.length) % 3 == 0
1068                    && !(o.length % 3 == 0 && c.length % 3 == 0);
1069                if !odd {
1070                    found = Some(oi as usize);
1071                    break;
1072                }
1073            }
1074            oi -= 1;
1075        }
1076        let Some(fi) = found else {
1077            ci += 1;
1078            continue;
1079        };
1080        let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1081            2
1082        } else {
1083            1
1084        };
1085        let oend = delims[fi].pos + delims[fi].rem;
1086        for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1087            *c = true;
1088        }
1089        let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1090        for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1091            *c = true;
1092        }
1093        for e in emph.iter_mut().take(cstart).skip(oend) {
1094            *e = true;
1095        }
1096        delims[fi].rem -= use_;
1097        delims[ci].rem -= use_;
1098        delims.drain((fi + 1)..ci);
1099        ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1100    }
1101
1102    // Drop the consumed markers, then split into lines carrying their emphasis.
1103    let mut records: Vec<(String, bool)> = Vec::new();
1104    let mut line = String::new();
1105    let mut line_italic = false;
1106    let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1107        let text = std::mem::take(line);
1108        let ital = std::mem::replace(italic, false);
1109        let trimmed = text.trim();
1110        if trimmed.is_empty() {
1111            return;
1112        }
1113        // A Markdown thematic break (underscores only) normalizes to ten.
1114        let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1115            "_".repeat(10)
1116        } else {
1117            text
1118        };
1119        out.push((norm, ital));
1120    };
1121    for k in 0..n {
1122        if consumed[k] {
1123            continue;
1124        }
1125        if chars[k] == '\n' {
1126            push_line(&mut line, &mut line_italic, &mut records);
1127        } else {
1128            line.push(chars[k]);
1129            if emph[k] {
1130                line_italic = true;
1131            }
1132        }
1133    }
1134    push_line(&mut line, &mut line_italic, &mut records);
1135    records
1136}
1137
1138/// Render a [`Node::InlineGroup`] — docling's `InlineGroup`. Reproduces the
1139/// reference's `minidom.toprettyxml` layout, which is fully determined by how
1140/// `writexml` writes text nodes (`indent + data + newl`) once the runs are
1141/// joined by the `"\n"` record delimiter and the empty-line filter runs:
1142///
1143/// * A styled run becomes a nested element (`<italic><bold>…`) via
1144///   [`emit_styled`]; leaf elements inline, multi-layer ones in block form.
1145/// * A plain run is a bare text node. Its `"\n"`-delimited leading newline
1146///   pushes it to column 0 — except the *first* child of a `<text>` wrapper,
1147///   which has no leading newline and stays indented.
1148/// * `unwrapped` groups (docling parent is a heading/text) carry no `<text>`;
1149///   an all-plain wrapped group collapses to a single inline text node with a
1150///   trailing newline before `</text>`.
1151fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1152    let has_styled = runs.iter().any(|r| !r.is_plain());
1153
1154    if unwrapped {
1155        for run in runs {
1156            if run.is_plain() {
1157                out.push(0, escape_text(&run.text));
1158            } else if run.formula {
1159                out.push(
1160                    depth,
1161                    format!("<formula>{}</formula>", escape_text(&run.text)),
1162                );
1163            } else {
1164                emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1165            }
1166        }
1167        return;
1168    }
1169
1170    // Wrapped: an all-plain group is a single text node — inline form, runs
1171    // joined by "\n" with the serializer's trailing "\n" before `</text>`.
1172    if !has_styled {
1173        let joined = runs
1174            .iter()
1175            .map(|r| escape_text(&r.text))
1176            .collect::<Vec<_>>()
1177            .join("\n");
1178        out.push(depth, format!("<text>{joined}\n</text>"));
1179        return;
1180    }
1181
1182    out.push(depth, "<text>".to_string());
1183    emit_inline_runs_body(out, depth + 1, runs);
1184    out.push(depth, "</text>".to_string());
1185}
1186
1187/// Emit the child runs of an inline group at `depth` (the body shared by a
1188/// wrapped `<text>` group and a list item's bare content). A `<content>`-wrapped
1189/// run is an *element* child → indented; a bare text/CDATA node sits at column 0
1190/// (its record delimiter's leading newline), except the first child, which has
1191/// no leading newline and stays indented. Styled/formula runs are elements.
1192fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1193    for (i, run) in runs.iter().enumerate() {
1194        if run.is_plain() {
1195            let e = escape_text(&run.text);
1196            let d = if e.starts_with("<content>") || i == 0 {
1197                depth
1198            } else {
1199                0
1200            };
1201            if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1202                // minidom writes a trailing CDATA bare (no newline); the "\n"
1203                // record delimiter that follows still writes its indentation
1204                // before its newline, leaving trailing spaces on the CDATA line
1205                // (the blank line it opens is dropped by the empty-line filter).
1206                out.push_glue(e);
1207                out.push(depth, "");
1208            } else {
1209                out.push(d, e);
1210            }
1211        } else if run.formula {
1212            out.push(
1213                depth,
1214                format!("<formula>{}</formula>", escape_text(&run.text)),
1215            );
1216        } else {
1217            emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1218        }
1219    }
1220}
1221
1222/// The DocLang wrapping tags for a run, outermost first. docling applies
1223/// formatting in the order bold → italic → underline → strikethrough → script,
1224/// each wrapping the previous result, so the *last* applied is the outermost.
1225fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1226    let mut tags = Vec::new();
1227    match run.script {
1228        Script::Sub => tags.push("subscript"),
1229        Script::Super => tags.push("superscript"),
1230        Script::Baseline => {}
1231    }
1232    if run.strike {
1233        tags.push("strikethrough");
1234    }
1235    if run.underline {
1236        tags.push("underline");
1237    }
1238    if run.italic {
1239        tags.push("italic");
1240    }
1241    if run.bold {
1242        tags.push("bold");
1243    }
1244    if run.code {
1245        tags.push("code");
1246    }
1247    tags
1248}
1249
1250/// Emit a linear chain of wrapping `tags` (outer→inner) around `inner` text. A
1251/// single tag renders inline (`<bold>x</bold>`); nested tags render block-form,
1252/// the innermost (a text child) inline — matching minidom's single-text-child
1253/// rule at each level.
1254fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1255    match tags {
1256        [] => emit_text_node(out, depth, inner),
1257        [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1258        [tag, rest @ ..] => {
1259            out.push(depth, format!("<{tag}>"));
1260            emit_styled(out, depth + 1, rest, inner);
1261            out.push(depth, format!("</{tag}>"));
1262        }
1263    }
1264}
1265
1266/// Render a [`Node::Furniture`] wrapper: the inner element with a
1267/// `<layer value="{layer}"/>` head (which forces the block form). Headings (the
1268/// HTML `<title>`, section chrome) and body text (docx comments, nav items) are
1269/// emitted with the layer token; other nodes fall back to their body rendering.
1270fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1271    let token = format!("<layer value=\"{}\"/>", layer.value());
1272    match inner {
1273        Node::Heading { level, text } => {
1274            let open = if *level <= 1 {
1275                "heading".to_string()
1276            } else {
1277                format!("heading level=\"{level}\"")
1278            };
1279            out.push(depth, format!("<{open}>"));
1280            out.push(depth + 1, token);
1281            out.push(depth + 1, escape_text(text));
1282            out.push(depth, "</heading>".to_string());
1283        }
1284        Node::Paragraph { text } => {
1285            out.push(depth, "<text>".to_string());
1286            out.push(depth + 1, token);
1287            out.push(depth + 1, escape_text(text));
1288            out.push(depth, "</text>".to_string());
1289        }
1290        // A located notes text (PPTX speaker notes: docling gives them a zero
1291        // bbox provenance): layer token first, then the location tokens.
1292        Node::Located { location, inner } => {
1293            if let Node::Paragraph { text } = &**inner {
1294                out.push(depth, "<text>".to_string());
1295                out.push(depth + 1, token);
1296                push_location(out, depth + 1, location);
1297                out.push(depth + 1, escape_text(text));
1298                out.push(depth, "</text>".to_string());
1299            } else {
1300                let mut i = 0usize;
1301                emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1302            }
1303        }
1304        // A furniture inline group (a mixed-formatting header/footer paragraph):
1305        // wrapped in `<text>`, with each child run carrying its own layer token
1306        // (docling stamps the layer on every text item of the group).
1307        Node::InlineGroup { runs, .. } => {
1308            out.push(depth, "<text>".to_string());
1309            for run in runs {
1310                out.push(depth + 1, token.clone());
1311                if run.is_plain() {
1312                    out.push(depth + 1, escape_text(&run.text));
1313                } else if run.formula {
1314                    out.push(
1315                        depth + 1,
1316                        format!("<formula>{}</formula>", escape_text(&run.text)),
1317                    );
1318                } else {
1319                    emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1320                }
1321            }
1322            out.push(depth, "</text>".to_string());
1323        }
1324        // A furniture picture (site-chrome logo/banner, header/footer image):
1325        // the layer token, an embedded-image `<src>` when the picture carries
1326        // pixels (docling's referenced-asset conversion skips furniture, so the
1327        // image stays a base64 data URI), then a caption that carries its own
1328        // `<href>`/`<layer>` head when the caption is a link.
1329        Node::Picture { caption, image, .. } => {
1330            let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1331            out.push(depth, "<picture>".to_string());
1332            out.push(depth + 1, token.clone());
1333            if let Some(img) = image {
1334                out.push(
1335                    depth + 1,
1336                    format!(
1337                        "<src uri=\"data:image/png;base64,{}\"/>",
1338                        crate::base64::encode(&img.data)
1339                    ),
1340                );
1341            }
1342            if let Some(c) = caption {
1343                out.push(depth + 1, "<caption>".to_string());
1344                match inline_runs(c).into_iter().next() {
1345                    Some(Run::Link { anchor, uri }) => {
1346                        out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1347                        out.push(depth + 2, token);
1348                        out.push(depth + 2, escape_text(&anchor));
1349                    }
1350                    _ => {
1351                        out.push(depth + 2, token);
1352                        out.push(depth + 2, escape_text(c));
1353                    }
1354                }
1355                out.push(depth + 1, "</caption>".to_string());
1356            }
1357            out.push(depth, "</picture>".to_string());
1358        }
1359        // An invisible-layer table (a hidden spreadsheet sheet): the layer
1360        // token precedes the location/cells inside the `<table>`.
1361        Node::Table(table) => {
1362            out.push(depth, "<table>".to_string());
1363            out.push(depth + 1, token);
1364            emit_table_rows(out, depth, table);
1365            out.push(depth, "</table>".to_string());
1366        }
1367        other => {
1368            let mut i = 0usize;
1369            emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1370        }
1371    }
1372}
1373
1374/// Render a `<picture>` — with optional layout provenance and caption. Empty
1375/// (no location, no caption) collapses to `<picture></picture>`.
1376fn emit_picture(
1377    out: &mut Out,
1378    depth: i32,
1379    caption: Option<&str>,
1380    image: Option<&crate::document::PictureImage>,
1381    location: Option<&[u16; 4]>,
1382) {
1383    let caption = caption.filter(|c| !c.trim().is_empty());
1384    // An image-bearing picture carries a referenced-image `<src>` naming the
1385    // exported asset (`assets/image_{index:06}_{sha256}.png`), matching docling's
1386    // referenced-image mode. docling re-encodes every image to PNG through PIL, so
1387    // the extension is always `.png` and the content hash is over those re-encoded
1388    // bytes — not reproducible here, so we hash the source bytes and the
1389    // conformance harness canonicalizes the digest before comparing.
1390    let src = image.map(|img| {
1391        let idx = out.pic_index;
1392        out.pic_index += 1;
1393        format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1394    });
1395    if location.is_none() && caption.is_none() && src.is_none() {
1396        out.push(depth, "<picture></picture>".to_string());
1397        return;
1398    }
1399    out.push(depth, "<picture>".to_string());
1400    if let Some(loc) = location {
1401        push_location(out, depth + 1, loc);
1402    }
1403    if let Some(s) = src {
1404        out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1405    }
1406    if let Some(c) = caption {
1407        emit_caption(out, depth + 1, c);
1408    }
1409    out.push(depth, "</picture>".to_string());
1410}
1411
1412/// A `<caption>` — inline when plain text, or block form with an `<href uri=…/>`
1413/// head + anchor text when the caption is a single Markdown link (docling's
1414/// linked image captions).
1415fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1416    if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1417        if inline_runs(text).len() == 1 {
1418            out.push(depth, "<caption>".to_string());
1419            out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1420            out.push(depth + 1, escape_text(&anchor));
1421            out.push(depth, "</caption>".to_string());
1422            return;
1423        }
1424    }
1425    out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1426}
1427
1428/// If `text` is a single `[anchor](uri)` Markdown link, return just `anchor`;
1429/// otherwise return `text` unchanged. Used when the link's uri rides in a list
1430/// item's `<href>` head, so the content keeps only the anchor text.
1431fn strip_lone_link(text: &str) -> Cow<'_, str> {
1432    if let Some(rest) = text.strip_prefix('[') {
1433        if let Some(close) = rest.find("](") {
1434            if rest.ends_with(')') {
1435                let anchor = &rest[..close];
1436                let uri = &rest[close + 2..rest.len() - 1];
1437                if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1438                    return Cow::Owned(anchor.to_string());
1439                }
1440            }
1441        }
1442    }
1443    Cow::Borrowed(text)
1444}
1445
1446/// Lowercase hex SHA-256 of `bytes` (image asset content hash).
1447fn sha256_hex(bytes: &[u8]) -> String {
1448    use sha2::{Digest, Sha256};
1449    let mut h = Sha256::new();
1450    h.update(bytes);
1451    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1452}
1453
1454/// Render a [`Node::Located`] wrapper: the inner element with its `<location>`
1455/// tokens as the first children.
1456fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1457    match inner {
1458        Node::Heading { level, text } => {
1459            let open = if *level <= 1 {
1460                "heading".to_string()
1461            } else {
1462                format!("heading level=\"{level}\"")
1463            };
1464            emit_text_element(out, depth, &open, "heading", text, Some(location));
1465        }
1466        Node::Paragraph { text } => {
1467            emit_text_element(out, depth, "text", "text", text, Some(location));
1468        }
1469        Node::Picture { caption, image, .. } => {
1470            emit_picture(
1471                out,
1472                depth,
1473                caption.as_deref(),
1474                image.as_ref(),
1475                Some(location),
1476            );
1477        }
1478        Node::Table(t) => {
1479            // The wrapper's location takes precedence over any on the table.
1480            let mut t = t.clone();
1481            t.location = Some(*location);
1482            emit_table(out, depth, &t);
1483        }
1484        Node::Code { language, text, .. } => {
1485            emit_code(out, depth, language.as_deref(), text, Some(location));
1486        }
1487        // Other node kinds carry no location today — render them as-is.
1488        // (Located list items are routed to emit_list by emit_nodes so they
1489        // still group into one `<list>`.)
1490        other => {
1491            let mut i = 0usize;
1492            emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1493        }
1494    }
1495}
1496
1497fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1498    // The list kind follows the first item's DocLang overlay when it has one
1499    // (a docx multilevel item is a Markdown bullet but a DocLang ordered item).
1500    let ordered = match &nodes[*i] {
1501        Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1502        _ => false,
1503    };
1504    let open = if ordered {
1505        "<list class=\"ordered\">"
1506    } else {
1507        "<list>"
1508    };
1509    out.push(depth, open.to_string());
1510    let start = *i;
1511    let mut prev_number: Option<u64> = None;
1512    while *i < nodes.len() {
1513        match &nodes[*i] {
1514            Node::ListItem {
1515                level: l,
1516                text,
1517                marker,
1518                ordered: o,
1519                number,
1520                first_in_list,
1521                location,
1522                dclx,
1523                href,
1524                layer,
1525            } if *l == level => {
1526                // The DocLang overlay wins over the flat Markdown fields for the
1527                // list kind and marker (see `ListItemDclx`).
1528                let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1529                let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1530                // A new sibling list at this depth closes this one (the caller
1531                // re-opens): the backend flagged a fresh list, the kind flips, or
1532                // an ordered run breaks — matching the Markdown serializer.
1533                if *i != start
1534                    && (*first_in_list
1535                        || eff_ordered != ordered
1536                        || (ordered && Some(*number) != prev_number.map(|n| n + 1)))
1537                {
1538                    break;
1539                }
1540                prev_number = Some(*number);
1541                // docling wraps a list item's content in `<text>` when a nested
1542                // list follows it *anywhere* inside the same `<list>` — its
1543                // `_list_item_has_segment_siblings` scans the parent group's
1544                // children after the item; a plain item with no later nested
1545                // list stays bare.
1546                let has_nested = {
1547                    let mut found = false;
1548                    let mut pn = Some(*number);
1549                    let mut j = *i + 1;
1550                    while let Some(Node::ListItem {
1551                        level: nl,
1552                        ordered: no,
1553                        number: nn,
1554                        first_in_list: nf,
1555                        dclx: nd,
1556                        ..
1557                    }) = nodes.get(j)
1558                    {
1559                        if *nl > level {
1560                            found = true;
1561                            break;
1562                        }
1563                        if *nl < level {
1564                            break;
1565                        }
1566                        // The same run-break rules as the main loop: a sibling
1567                        // list at this depth ends this `<list>` element.
1568                        let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1569                        if *nf
1570                            || n_ordered != ordered
1571                            || (ordered && Some(*nn) != pn.map(|n| n + 1))
1572                        {
1573                            break;
1574                        }
1575                        pn = Some(*nn);
1576                        j += 1;
1577                    }
1578                    found
1579                };
1580                // An enumeration marker (HTML/DOCX ordered items) rides inside
1581                // the `<ldiv>`; without one the delimiter is self-closing.
1582                match eff_marker {
1583                    Some(m) => {
1584                        out.push(depth + 1, "<ldiv>".to_string());
1585                        out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1586                        out.push(depth + 1, "</ldiv>".to_string());
1587                    }
1588                    None => out.push(depth + 1, "<ldiv/>".to_string()),
1589                }
1590                // Layout provenance (PPTX shapes): the four `<location>` tokens
1591                // follow the `<ldiv>` and precede the item's content, matching
1592                // docling's element head inside the list.
1593                if let Some(loc) = location {
1594                    push_location(out, depth + 1, loc);
1595                }
1596                match dclx {
1597                    // Structured DocLang content (equations/formatting): the runs
1598                    // render directly. The same `<text>` wrap rule as plain items
1599                    // applies — a nested list following the item wraps its
1600                    // content (docling's `_list_item_has_segment_siblings`).
1601                    Some(d) if !d.runs.is_empty() => {
1602                        if has_nested {
1603                            out.push(depth + 1, "<text>".to_string());
1604                            emit_inline_runs_body(out, depth + 2, &d.runs);
1605                            out.push(depth + 1, "</text>".to_string());
1606                        } else {
1607                            emit_inline_runs_body(out, depth + 1, &d.runs);
1608                        }
1609                    }
1610                    // A clean-text override (multilevel numbering) re-parses like
1611                    // a normal item but from the overlay's text.
1612                    Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1613                    None => {
1614                        // docling emits an `<href>` head only when the item's whole
1615                        // content is a lone link (`[anchor](uri)`); a mixed item
1616                        // (`text [anchor](uri) …`) keeps the anchor inline with no
1617                        // head. A non-body layer always rides in the head.
1618                        let stripped = strip_lone_link(text);
1619                        let eff_href = href
1620                            .as_deref()
1621                            .filter(|_| matches!(stripped, Cow::Owned(_)));
1622                        if eff_href.is_some() || layer.is_some() {
1623                            let content: &str = if eff_href.is_some() {
1624                                stripped.as_ref()
1625                            } else {
1626                                text.as_str()
1627                            };
1628                            emit_list_item_with_head(
1629                                out,
1630                                depth + 1,
1631                                content,
1632                                has_nested,
1633                                eff_href,
1634                                *layer,
1635                            );
1636                        } else {
1637                            emit_list_item_content(out, depth + 1, text, has_nested);
1638                        }
1639                    }
1640                }
1641                *i += 1;
1642            }
1643            Node::ListItem { level: l, .. } if *l > level => {
1644                emit_list(out, depth + 1, nodes, i, *l);
1645            }
1646            // An empty paragraph between two items of the *same* list run is
1647            // absorbed (docling deletes the empty text it added on close when
1648            // it reuses the ListGroup for the same numId). When the next item
1649            // starts a *new* list (fresh-list flag, kind flip, or an ordered
1650            // sequence break), no reuse happens and the empty text survives.
1651            Node::Paragraph { text }
1652                if text.is_empty()
1653                    && matches!(
1654                        nodes.get(*i + 1),
1655                        Some(Node::ListItem { level: nl, ordered: no, number: nn,
1656                                              first_in_list: nf, dclx: nd, .. })
1657                            if *nl > level
1658                                || (*nl == level
1659                                    && !*nf
1660                                    && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1661                                    && (!ordered
1662                                        || Some(*nn) == prev_number.map(|n| n + 1)))
1663                    ) =>
1664            {
1665                *i += 1;
1666            }
1667            _ => break,
1668        }
1669    }
1670    out.push(depth, "</list>".to_string());
1671}
1672
1673/// Render a list item's content after its `<ldiv/>`. docling wraps the content
1674/// in `<text>` when the item has a "segment sibling" — a nested list following
1675/// it — and otherwise emits it bare (a plain item as indented text, a formatted
1676/// one as its inline elements). (A uniformly-formatted item that docling stores
1677/// with direct formatting rather than an inline group is also wrapped, but that
1678/// backend-structural distinction isn't recoverable from the flat model.)
1679/// A list item whose head carries an `<href>` and/or `<layer>` (HTML links /
1680/// site chrome). Bare content puts the head right after the `<ldiv>` then the
1681/// anchor text; wrapped content (a `<text>` element, e.g. an item with a nested
1682/// sublist) puts the head *inside* the `<text>`.
1683fn emit_list_item_with_head(
1684    out: &mut Out,
1685    depth: i32,
1686    text: &str,
1687    has_nested: bool,
1688    href: Option<&str>,
1689    layer: Option<ContentLayer>,
1690) {
1691    let head = |out: &mut Out, d: i32| {
1692        if let Some(uri) = href {
1693            out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1694        }
1695        if let Some(l) = layer {
1696            out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1697        }
1698    };
1699    if has_nested {
1700        out.push(depth, "<text>".to_string());
1701        head(out, depth + 1);
1702        emit_runs(out, depth + 1, inline_runs(text));
1703        out.push(depth, "</text>".to_string());
1704    } else {
1705        head(out, depth);
1706        emit_runs(out, depth, inline_runs(text));
1707    }
1708}
1709
1710fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1711    // docling models an HTML list item's inline content as an InlineGroup: each
1712    // text node / inline element becomes a separate child, links flatten to
1713    // their anchor (the href is dropped in inline scope), and the children are
1714    // rendered on their own lines. Re-parse the Markdown markers into runs and
1715    // mirror that layout.
1716    let runs = inline_runs_from_markdown(text);
1717    let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1718    if single_plain {
1719        if has_nested {
1720            emit_text_element(out, depth, "text", "text", text, None);
1721        } else if !text.trim().is_empty() {
1722            // The original text, not the re-parsed run: an unformatted item keeps
1723            // its raw boundary whitespace (docling stores the backend's run text
1724            // verbatim, and the serializer preserves it with `<content>`).
1725            emit_text_node(out, depth, text);
1726        }
1727    } else if has_nested {
1728        emit_inline_group(out, depth, false, &runs);
1729    } else {
1730        // Bare multi-segment item: the runs render at the item's own depth, the
1731        // first indented and the rest column-0 (minidom's text-child layout).
1732        emit_inline_runs_body(out, depth, &runs);
1733    }
1734}
1735
1736fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1737    out.push(depth, "<field_region>".to_string());
1738    for item in items {
1739        out.push(depth + 1, "<field_item>".to_string());
1740        if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1741            out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1742        }
1743        if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1744            out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1745        }
1746        if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1747            out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1748        }
1749        out.push(depth + 1, "</field_item>".to_string());
1750    }
1751    out.push(depth, "</field_region>".to_string());
1752}
1753
1754#[cfg(test)]
1755mod tests {
1756    use super::*;
1757
1758    #[test]
1759    fn located_heading_emits_location_tokens_in_block_form() {
1760        let doclang = export_to_doclang(&[Node::Located {
1761            location: [44, 170, 340, 386],
1762            inner: Box::new(Node::Heading {
1763                level: 1,
1764                text: "X-Library".into(),
1765            }),
1766        }]);
1767        assert!(
1768            doclang.contains(
1769                "<heading>\n    <location value=\"44\"/>\n    <location value=\"170\"/>\n    \
1770                 <location value=\"340\"/>\n    <location value=\"386\"/>\n    X-Library\n  </heading>"
1771            ),
1772            "got:\n{doclang}"
1773        );
1774    }
1775
1776    fn code(language: Option<&str>, text: &str) -> String {
1777        export_to_doclang(&[Node::Code {
1778            language: language.map(String::from),
1779            text: text.into(),
1780            orig: None,
1781            pretty: None,
1782        }])
1783    }
1784
1785    #[test]
1786    fn code_with_language_emits_linguist_label_block_form() {
1787        // Fence language folds through the docling value onto the Linguist key,
1788        // forcing the block form; CDATA text glues the closing tag.
1789        assert_eq!(
1790            code(Some("python"), "print(\"Hello world!\")"),
1791            "<doclang version=\"0.7\">\n  <code>\n    <label value=\"Python\"/>\n\
1792             <![CDATA[print(\"Hello world!\")]]>  </code>\n</doclang>"
1793        );
1794        // Aliased label: bash -> Shell.
1795        assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1796    }
1797
1798    fn plain(text: &str) -> InlineRun {
1799        InlineRun {
1800            text: text.into(),
1801            ..Default::default()
1802        }
1803    }
1804    fn bold(text: &str) -> InlineRun {
1805        InlineRun {
1806            text: text.into(),
1807            bold: true,
1808            ..Default::default()
1809        }
1810    }
1811    fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1812        let body = export_to_doclang(&[Node::InlineGroup {
1813            unwrapped,
1814            runs,
1815            md_text: String::new(),
1816        }]);
1817        // strip the <doclang> envelope for readable assertions
1818        body.trim_start_matches("<doclang version=\"0.7\">\n")
1819            .trim_end_matches("\n</doclang>")
1820            .to_string()
1821    }
1822
1823    #[test]
1824    fn inline_group_matches_reference_layout() {
1825        // wrapped, mixed: first text indented, post-element text at col 0.
1826        assert_eq!(
1827            ig(
1828                false,
1829                vec![plain("This is a"), bold("bold"), plain("example")]
1830            ),
1831            "  <text>\n    This is a\n    <bold>bold</bold>\nexample\n  </text>"
1832        );
1833        // unwrapped, mixed: text at col 0, elements at depth 1.
1834        assert_eq!(
1835            ig(
1836                true,
1837                vec![
1838                    plain("aa"),
1839                    bold("bb"),
1840                    plain("cc"),
1841                    bold("dd"),
1842                    plain("ee")
1843                ]
1844            ),
1845            "aa\n  <bold>bb</bold>\ncc\n  <bold>dd</bold>\nee"
1846        );
1847        // wrapped, all-plain: single text node with trailing newline.
1848        assert_eq!(
1849            ig(false, vec![plain("aa"), plain("bb")]),
1850            "  <text>aa\nbb\n</text>"
1851        );
1852        assert_eq!(ig(false, vec![plain("aa")]), "  <text>aa\n</text>");
1853        // wrapped, single element.
1854        assert_eq!(
1855            ig(false, vec![bold("bb")]),
1856            "  <text>\n    <bold>bb</bold>\n  </text>"
1857        );
1858    }
1859
1860    #[test]
1861    fn nested_styles_wrap_outermost_last_applied() {
1862        let bi = InlineRun {
1863            text: "bi".into(),
1864            bold: true,
1865            italic: true,
1866            ..Default::default()
1867        };
1868        // italic (applied after bold) is outermost; block form.
1869        assert_eq!(
1870            ig(true, vec![bi]),
1871            "  <italic>\n    <bold>bi</bold>\n  </italic>"
1872        );
1873        let sub = InlineRun {
1874            text: "2".into(),
1875            script: Script::Sub,
1876            ..Default::default()
1877        };
1878        assert_eq!(ig(true, vec![sub]), "  <subscript>2</subscript>");
1879    }
1880
1881    #[test]
1882    fn furniture_heading_gets_layer_head() {
1883        let out = export_to_doclang(&[Node::Furniture {
1884            layer: ContentLayer::Furniture,
1885            inner: Box::new(Node::Heading {
1886                level: 1,
1887                text: "Anchor Links Test".into(),
1888            }),
1889        }]);
1890        assert_eq!(
1891            out,
1892            "<doclang version=\"0.7\">\n  <heading>\n    <layer value=\"furniture\"/>\n    Anchor Links Test\n  </heading>\n</doclang>"
1893        );
1894    }
1895
1896    #[test]
1897    fn text_dump_reproduces_minidom_per_line_layout() {
1898        // A plain-text dump: the first record indents, later plain records sit at
1899        // column 0, a line with `"`/`&` becomes CDATA (with the next child's indent
1900        // glued on as trailing whitespace), a `*`…`*` span becomes per-line
1901        // `<italic>`, and an underscore rule collapses to ten underscores.
1902        let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1903        let out = export_to_doclang(&[Node::TextDump(text.into())]);
1904        let expected = "<doclang version=\"0.7\">\n  \
1905             <text>\n    \
1906             PATN\nWKU 1\n\
1907             <![CDATA[PAL K. \"Determination\"]]>    \n\
1908             follow-up\n    \
1909             <italic>Note A</italic>\n    \
1910             <italic>__________</italic>\n    \
1911             <italic>Note B</italic>\n\
1912             END\n  \
1913             </text>\n</doclang>";
1914        assert_eq!(out, expected, "got:\n{out}");
1915    }
1916
1917    #[test]
1918    fn code_without_language_stays_inline_and_unlabeled() {
1919        assert_eq!(
1920            code(None, "print(\"Hi!\")"),
1921            "<doclang version=\"0.7\">\n  <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1922        );
1923        // Unknown fence language: no label, still inline.
1924        assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1925    }
1926}