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