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    emit_table_rows(out, depth, table);
593    out.push(depth, "</table>".to_string());
594}
595
596/// A chart — docling's `PictureItem` with a tabular chart-data annotation:
597/// `<picture class="chart">` wrapping a `<label value="{kind}"/>` and the data
598/// grid as a `<tabular>` (same cell tokens as a table).
599fn emit_chart(
600    out: &mut Out,
601    depth: i32,
602    kind: &str,
603    table: &Table,
604    caption: Option<&str>,
605    location: Option<&[u16; 4]>,
606) {
607    out.pic_index += 1;
608    out.push(depth, "<picture class=\"chart\">".to_string());
609    out.push(
610        depth + 1,
611        format!("<label value=\"{}\"/>", attr_escape(kind)),
612    );
613    if let Some(loc) = location {
614        push_location(out, depth + 1, loc);
615    }
616    if let Some(cap) = caption {
617        out.push(
618            depth + 1,
619            format!("<caption>{}</caption>", escape_text(cap)),
620        );
621    }
622    out.push(depth + 1, "<tabular>".to_string());
623    emit_table_rows(out, depth + 1, table);
624    out.push(depth + 1, "</tabular>".to_string());
625    out.push(depth, "</picture>".to_string());
626}
627
628/// Emit a grid's cells (the shared body of `<table>` and a chart's `<tabular>`):
629/// the location head, then each row's OTSL cell tokens at `depth + 1`.
630fn emit_table_rows(out: &mut Out, depth: i32, table: &Table) {
631    // Layout provenance (spreadsheet/slide backends): four `<location>` tokens
632    // (x0,y0,x1,y1) precede the cells, matching docling's element head.
633    if let Some(loc) = &table.location {
634        push_location(out, depth + 1, loc);
635    }
636    for (ri, row) in table.rows.iter().enumerate() {
637        for (ci, cell) in row.iter().enumerate() {
638            // A span continuation is a token-only cell (no text child):
639            // horizontal → `<lcel/>`, vertical → `<ucel/>`. Otherwise
640            // empty→`<ecel/>`, header→`<ched/>`, else `<fcel/>`.
641            let cont = |grid: &Vec<Vec<bool>>| {
642                grid.get(ri)
643                    .and_then(|r| r.get(ci))
644                    .copied()
645                    .unwrap_or(false)
646            };
647            let is_lcel = table
648                .structure
649                .as_ref()
650                .map(|s| cont(&s.col_continuation))
651                .unwrap_or(false);
652            let is_ucel = table
653                .structure
654                .as_ref()
655                .map(|s| cont(&s.row_continuation))
656                .unwrap_or(false);
657            let is_header = match &table.structure {
658                Some(s) if !s.col_header.is_empty() => s
659                    .col_header
660                    .get(ri)
661                    .and_then(|r| r.get(ci))
662                    .copied()
663                    .unwrap_or(false),
664                Some(s) => s.header_row.get(ri).copied().unwrap_or(false),
665                None => ri == 0,
666            };
667            let is_row_header = table
668                .structure
669                .as_ref()
670                .map(|s| {
671                    s.row_header
672                        .get(ri)
673                        .and_then(|r| r.get(ci))
674                        .copied()
675                        .unwrap_or(false)
676                })
677                .unwrap_or(false);
678            let tok = if is_lcel && is_ucel {
679                // Continues a span in both axes (a 2-D covered cell) → `<xcel/>`.
680                "<xcel/>"
681            } else if is_lcel {
682                "<lcel/>"
683            } else if is_ucel {
684                "<ucel/>"
685            } else if cell.trim().is_empty() {
686                "<ecel/>"
687            } else if is_header {
688                "<ched/>"
689            } else if is_row_header {
690                "<rhed/>"
691            } else {
692                "<fcel/>"
693            };
694            out.push(depth + 1, tok.to_string());
695            if !is_lcel && !is_ucel {
696                // A rich cell (ODF lists / nested tables / multi-paragraph)
697                // emits its structured blocks after the token; otherwise the
698                // flat cell text renders inline.
699                let blocks = table
700                    .cell_blocks
701                    .as_ref()
702                    .and_then(|b| b.get(ri))
703                    .and_then(|r| r.get(ci))
704                    .filter(|b| !b.is_empty());
705                if let Some(blocks) = blocks {
706                    let mut bi = 0;
707                    emit_nodes(out, depth + 1, blocks, &mut bi, 0);
708                } else if !cell.trim().is_empty() {
709                    emit_cell_text(out, depth + 1, cell);
710                }
711            }
712        }
713        out.push(depth + 1, "<nl/>".to_string());
714    }
715}
716
717/// Table-cell content: virtual text (no wrapper), inline markers re-parsed.
718fn emit_cell_text(out: &mut Out, depth: i32, text: &str) {
719    let runs = inline_runs(text.trim());
720    emit_runs(out, depth, runs);
721}
722
723/// Serialize the node stream to DocLang XML (no trailing newline).
724pub fn export_to_doclang(nodes: &[Node]) -> String {
725    let mut out = Out {
726        lines: Vec::new(),
727        pic_index: 0,
728    };
729    out.push(0, "<doclang version=\"0.7\">".to_string());
730    let mut i = 0usize;
731    emit_nodes(&mut out, 1, nodes, &mut i, 0);
732    out.push(0, "</doclang>".to_string());
733    out.finish()
734}
735
736/// Emit nodes at list-nesting `level`; consumes consecutive ListItems into
737/// `<list>` blocks (recursing for deeper levels).
738fn emit_nodes(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
739    while *i < nodes.len() {
740        match &nodes[*i] {
741            Node::Heading { level, text } => {
742                let open = if *level <= 1 {
743                    "heading".to_string()
744                } else {
745                    format!("heading level=\"{level}\"")
746                };
747                emit_text_element(out, depth, &open, "heading", text, None);
748                *i += 1;
749            }
750            Node::Paragraph { text } => {
751                // A standalone display equation (docling's block `FormulaItem`) is
752                // stored as a `$$…$$` paragraph so Markdown/JSON render the fenced
753                // math; DocLang emits it as a `<formula>` element.
754                if let Some(latex) = text
755                    .strip_prefix("$$")
756                    .and_then(|t| t.strip_suffix("$$"))
757                    .filter(|t| !t.is_empty())
758                {
759                    out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
760                } else {
761                    emit_text_element(out, depth, "text", "text", text, None);
762                }
763                *i += 1;
764            }
765            Node::CheckboxItem { checked, text } => {
766                // A `<text>` with a `<checkbox class="selected|unselected"/>` head
767                // element and the label text child (block form).
768                let class = if *checked { "selected" } else { "unselected" };
769                out.push(depth, "<text>".to_string());
770                out.push(depth + 1, format!("<checkbox class=\"{class}\"/>"));
771                if !text.is_empty() {
772                    out.push(depth + 1, escape_text(text));
773                }
774                out.push(depth, "</text>".to_string());
775                *i += 1;
776            }
777            Node::Code {
778                language,
779                text,
780                orig: _,
781            } => {
782                emit_code(out, depth, language.as_deref(), text, None);
783                *i += 1;
784            }
785            // A CodeFormula-decoded display formula: a `<formula>` element like
786            // the inline-math one, with the layout location when present.
787            Node::Formula {
788                latex, location, ..
789            } => {
790                if let Some(loc) = location {
791                    out.push(depth, "<formula>".to_string());
792                    push_location(out, depth + 1, loc);
793                    if !latex.is_empty() {
794                        out.push(depth + 1, escape_text(latex));
795                    }
796                    out.push(depth, "</formula>".to_string());
797                } else {
798                    out.push(depth, format!("<formula>{}</formula>", escape_text(latex)));
799                }
800                *i += 1;
801            }
802            Node::PageFurniture {
803                footer,
804                location,
805                text,
806            } => {
807                let tag = if *footer {
808                    "page_footer"
809                } else {
810                    "page_header"
811                };
812                out.push(depth, format!("<{tag}>"));
813                out.push(depth + 1, "<layer value=\"furniture\"/>".to_string());
814                push_location(out, depth + 1, location);
815                if !text.is_empty() {
816                    out.push(depth + 1, escape_text(text));
817                }
818                out.push(depth, format!("</{tag}>"));
819                *i += 1;
820            }
821            Node::Table(t) => {
822                emit_table(out, depth, t);
823                *i += 1;
824            }
825            // Classifier predictions are JSON-only; DocLang keeps the plain
826            // `<picture>` shape.
827            Node::Picture { caption, image, .. } => {
828                emit_picture(out, depth, caption.as_deref(), image.as_ref(), None);
829                *i += 1;
830            }
831            Node::Chart {
832                kind,
833                table,
834                caption,
835                location,
836            } => {
837                emit_chart(
838                    out,
839                    depth,
840                    kind,
841                    table,
842                    caption.as_deref(),
843                    location.as_ref(),
844                );
845                *i += 1;
846            }
847            Node::DoclangOnly(inner) => {
848                let mut j = 0;
849                emit_nodes(out, depth, std::slice::from_ref(inner), &mut j, level);
850                *i += 1;
851            }
852            Node::ListItem { level: l, .. } => {
853                if *l < level {
854                    return; // caller's list continues / closes
855                }
856                emit_list(out, depth, nodes, i, *l);
857            }
858            Node::Group { children, .. } => {
859                let mut j = 0usize;
860                emit_nodes(out, depth, children, &mut j, 0);
861                *i += 1;
862            }
863            Node::FieldRegion { items } => {
864                emit_field_region(out, depth, items);
865                *i += 1;
866            }
867            Node::InlineGroup {
868                unwrapped, runs, ..
869            } => {
870                emit_inline_group(out, depth, *unwrapped, runs);
871                *i += 1;
872            }
873            Node::Furniture { layer, inner } => {
874                emit_furniture(out, depth, *layer, inner);
875                *i += 1;
876            }
877            Node::Located { location, inner } => {
878                emit_located(out, depth, location, inner);
879                *i += 1;
880            }
881            Node::PageBreak => {
882                out.push(depth, "<page_break/>".to_string());
883                *i += 1;
884            }
885            Node::TextDump(text) => {
886                emit_text_dump(out, depth, text);
887                *i += 1;
888            }
889        }
890    }
891}
892
893/// One minidom child of the dump's `<text>`: a plain text node, a `<![CDATA[…]]>`
894/// section, or a formatted element (`<italic>…</italic>`).
895enum DumpNode {
896    Text(String),
897    Cdata(String),
898    Elem(String),
899}
900
901/// Render docling's plain-text backend dump: the whole file as one `<text>` item,
902/// serialized the way `xml.dom.minidom.toprettyxml` renders a `<text>` element.
903///
904/// docling applies inline Markdown to the text item, then builds a minified
905/// `<text>…</text>` string — each source line a record, `*`-emphasis converted to
906/// `<italic>`, XML-significant lines (`" ' & < >`) wrapped in `<![CDATA[…]]>` — and
907/// pretty-prints it, dropping blank lines. This reproduces that pipeline: parse the
908/// emphasis ([`dump_records`]), assemble the minidom child nodes, then simulate
909/// `toprettyxml`, which writes a text node as `indent + data`, a CDATA section as a
910/// bare `<![CDATA[…]]>` (no indent, no newline — so the next child's indent glues
911/// onto its line), and an element as `indent + <tag>…</tag>`.
912fn emit_text_dump(out: &mut Out, depth: i32, text: &str) {
913    let records = dump_records(text);
914    if records.is_empty() {
915        out.push(depth, "<text></text>".to_string());
916        return;
917    }
918    // Assemble the `<text>` element's minidom children. Consecutive plain records
919    // (and the `\n` record separators around them) collapse into one text node; a
920    // CDATA or formatted record breaks the run into its own node.
921    let mut nodes: Vec<DumpNode> = Vec::new();
922    let mut buf = String::new();
923    for (r, (line, italic)) in records.iter().enumerate() {
924        if r > 0 {
925            buf.push('\n'); // the record separator
926        }
927        let raw = unescape_stored(line);
928        let s = raw.as_ref();
929        let is_cdata = s.contains(['"', '\'', '&', '<', '>']);
930        if *italic || is_cdata {
931            if !buf.is_empty() {
932                nodes.push(DumpNode::Text(std::mem::take(&mut buf)));
933            }
934            let inner = if is_cdata {
935                format!("<![CDATA[{s}]]>")
936            } else {
937                s.to_string()
938            };
939            if *italic {
940                nodes.push(DumpNode::Elem(format!("<italic>{inner}</italic>")));
941            } else {
942                nodes.push(DumpNode::Cdata(inner));
943            }
944        } else {
945            buf.push_str(s);
946        }
947    }
948    if !buf.is_empty() {
949        nodes.push(DumpNode::Text(buf));
950    }
951
952    // A lone text node is a single text child — minidom renders it inline.
953    if let [DumpNode::Text(d)] = nodes.as_slice() {
954        out.push(depth, format!("<text>{d}\n</text>"));
955        return;
956    }
957
958    // Simulate `toprettyxml`: element/text children indent at depth+1, CDATA sits
959    // bare; then drop the blank lines docling's empty-line filter removes.
960    let ind_child = INDENT.repeat((depth + 1).max(0) as usize);
961    let ind_self = INDENT.repeat(depth.max(0) as usize);
962    let mut raw = String::new();
963    for node in &nodes {
964        match node {
965            DumpNode::Text(d) => {
966                raw.push_str(&ind_child);
967                raw.push_str(d);
968                raw.push('\n');
969            }
970            DumpNode::Cdata(b) => raw.push_str(b),
971            DumpNode::Elem(b) => {
972                raw.push_str(&ind_child);
973                raw.push_str(b);
974                raw.push('\n');
975            }
976        }
977    }
978    let full = format!("{ind_self}<text>\n{raw}{ind_self}</text>");
979    for line in full.split('\n') {
980        if !line.trim().is_empty() {
981            out.push(0, line.to_string());
982        }
983    }
984}
985
986/// Parse a plain-text dump into one record per line, applying docling's inline
987/// Markdown: CommonMark `*`/`**` emphasis (flanking rules + the delimiter-stack
988/// match) is stripped and its span flagged `italic`; a Markdown thematic break (a
989/// line of only underscores) collapses to ten underscores; blank lines drop out.
990fn dump_records(text: &str) -> Vec<(String, bool)> {
991    let chars: Vec<char> = text.chars().collect();
992    let n = chars.len();
993
994    struct Delim {
995        pos: usize,
996        length: usize,
997        rem: usize,
998        can_open: bool,
999        can_close: bool,
1000    }
1001    let is_ws = |c: Option<char>| c.is_none_or(|c| c.is_whitespace());
1002    let is_punct =
1003        |c: Option<char>| c.is_some_and(|c| "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".contains(c));
1004
1005    // Delimiter runs of `*`, each tagged left-/right-flanking (CommonMark 6.2).
1006    let mut delims: Vec<Delim> = Vec::new();
1007    let mut i = 0;
1008    while i < n {
1009        if chars[i] == '*' {
1010            let mut j = i;
1011            while j < n && chars[j] == '*' {
1012                j += 1;
1013            }
1014            let prev = (i > 0).then(|| chars[i - 1]);
1015            let next = (j < n).then(|| chars[j]);
1016            let left = !is_ws(next) && (!is_punct(next) || is_ws(prev) || is_punct(prev));
1017            let right = !is_ws(prev) && (!is_punct(prev) || is_ws(next) || is_punct(next));
1018            delims.push(Delim {
1019                pos: i,
1020                length: j - i,
1021                rem: j - i,
1022                can_open: left,
1023                can_close: right,
1024            });
1025            i = j;
1026        } else {
1027            i += 1;
1028        }
1029    }
1030
1031    // Match closers to the nearest eligible opener (CommonMark "process emphasis"),
1032    // marking the delimiter characters consumed and the spanned text emphasized.
1033    let mut emph = vec![false; n];
1034    let mut consumed = vec![false; n];
1035    let mut ci = 0;
1036    while ci < delims.len() {
1037        if !(delims[ci].can_close && delims[ci].rem > 0) {
1038            ci += 1;
1039            continue;
1040        }
1041        let mut found: Option<usize> = None;
1042        let mut oi = ci as i64 - 1;
1043        while oi >= 0 {
1044            let o = &delims[oi as usize];
1045            let c = &delims[ci];
1046            if o.can_open && o.rem > 0 {
1047                // "Rule of three": a run may not close its own kind when the
1048                // combined length is a multiple of three (unless both are).
1049                let odd = (o.can_close || c.can_open)
1050                    && (o.length + c.length) % 3 == 0
1051                    && !(o.length % 3 == 0 && c.length % 3 == 0);
1052                if !odd {
1053                    found = Some(oi as usize);
1054                    break;
1055                }
1056            }
1057            oi -= 1;
1058        }
1059        let Some(fi) = found else {
1060            ci += 1;
1061            continue;
1062        };
1063        let use_ = if delims[fi].rem >= 2 && delims[ci].rem >= 2 {
1064            2
1065        } else {
1066            1
1067        };
1068        let oend = delims[fi].pos + delims[fi].rem;
1069        for c in consumed.iter_mut().take(oend).skip(oend - use_) {
1070            *c = true;
1071        }
1072        let cstart = delims[ci].pos + (delims[ci].length - delims[ci].rem);
1073        for c in consumed.iter_mut().take(cstart + use_).skip(cstart) {
1074            *c = true;
1075        }
1076        for e in emph.iter_mut().take(cstart).skip(oend) {
1077            *e = true;
1078        }
1079        delims[fi].rem -= use_;
1080        delims[ci].rem -= use_;
1081        delims.drain((fi + 1)..ci);
1082        ci = if delims[fi].rem == 0 { fi + 1 } else { fi };
1083    }
1084
1085    // Drop the consumed markers, then split into lines carrying their emphasis.
1086    let mut records: Vec<(String, bool)> = Vec::new();
1087    let mut line = String::new();
1088    let mut line_italic = false;
1089    let push_line = |line: &mut String, italic: &mut bool, out: &mut Vec<(String, bool)>| {
1090        let text = std::mem::take(line);
1091        let ital = std::mem::replace(italic, false);
1092        let trimmed = text.trim();
1093        if trimmed.is_empty() {
1094            return;
1095        }
1096        // A Markdown thematic break (underscores only) normalizes to ten.
1097        let norm = if trimmed.len() >= 3 && trimmed.chars().all(|c| c == '_') {
1098            "_".repeat(10)
1099        } else {
1100            text
1101        };
1102        out.push((norm, ital));
1103    };
1104    for k in 0..n {
1105        if consumed[k] {
1106            continue;
1107        }
1108        if chars[k] == '\n' {
1109            push_line(&mut line, &mut line_italic, &mut records);
1110        } else {
1111            line.push(chars[k]);
1112            if emph[k] {
1113                line_italic = true;
1114            }
1115        }
1116    }
1117    push_line(&mut line, &mut line_italic, &mut records);
1118    records
1119}
1120
1121/// Render a [`Node::InlineGroup`] — docling's `InlineGroup`. Reproduces the
1122/// reference's `minidom.toprettyxml` layout, which is fully determined by how
1123/// `writexml` writes text nodes (`indent + data + newl`) once the runs are
1124/// joined by the `"\n"` record delimiter and the empty-line filter runs:
1125///
1126/// * A styled run becomes a nested element (`<italic><bold>…`) via
1127///   [`emit_styled`]; leaf elements inline, multi-layer ones in block form.
1128/// * A plain run is a bare text node. Its `"\n"`-delimited leading newline
1129///   pushes it to column 0 — except the *first* child of a `<text>` wrapper,
1130///   which has no leading newline and stays indented.
1131/// * `unwrapped` groups (docling parent is a heading/text) carry no `<text>`;
1132///   an all-plain wrapped group collapses to a single inline text node with a
1133///   trailing newline before `</text>`.
1134fn emit_inline_group(out: &mut Out, depth: i32, unwrapped: bool, runs: &[InlineRun]) {
1135    let has_styled = runs.iter().any(|r| !r.is_plain());
1136
1137    if unwrapped {
1138        for run in runs {
1139            if run.is_plain() {
1140                out.push(0, escape_text(&run.text));
1141            } else if run.formula {
1142                out.push(
1143                    depth,
1144                    format!("<formula>{}</formula>", escape_text(&run.text)),
1145                );
1146            } else {
1147                emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1148            }
1149        }
1150        return;
1151    }
1152
1153    // Wrapped: an all-plain group is a single text node — inline form, runs
1154    // joined by "\n" with the serializer's trailing "\n" before `</text>`.
1155    if !has_styled {
1156        let joined = runs
1157            .iter()
1158            .map(|r| escape_text(&r.text))
1159            .collect::<Vec<_>>()
1160            .join("\n");
1161        out.push(depth, format!("<text>{joined}\n</text>"));
1162        return;
1163    }
1164
1165    out.push(depth, "<text>".to_string());
1166    emit_inline_runs_body(out, depth + 1, runs);
1167    out.push(depth, "</text>".to_string());
1168}
1169
1170/// Emit the child runs of an inline group at `depth` (the body shared by a
1171/// wrapped `<text>` group and a list item's bare content). A `<content>`-wrapped
1172/// run is an *element* child → indented; a bare text/CDATA node sits at column 0
1173/// (its record delimiter's leading newline), except the first child, which has
1174/// no leading newline and stays indented. Styled/formula runs are elements.
1175fn emit_inline_runs_body(out: &mut Out, depth: i32, runs: &[InlineRun]) {
1176    for (i, run) in runs.iter().enumerate() {
1177        if run.is_plain() {
1178            let e = escape_text(&run.text);
1179            let d = if e.starts_with("<content>") || i == 0 {
1180                depth
1181            } else {
1182                0
1183            };
1184            if e.starts_with("<![CDATA[") && i + 1 == runs.len() && d == 0 {
1185                // minidom writes a trailing CDATA bare (no newline); the "\n"
1186                // record delimiter that follows still writes its indentation
1187                // before its newline, leaving trailing spaces on the CDATA line
1188                // (the blank line it opens is dropped by the empty-line filter).
1189                out.push_glue(e);
1190                out.push(depth, "");
1191            } else {
1192                out.push(d, e);
1193            }
1194        } else if run.formula {
1195            out.push(
1196                depth,
1197                format!("<formula>{}</formula>", escape_text(&run.text)),
1198            );
1199        } else {
1200            emit_styled(out, depth, &style_tags(run), &escape_text(&run.text));
1201        }
1202    }
1203}
1204
1205/// The DocLang wrapping tags for a run, outermost first. docling applies
1206/// formatting in the order bold → italic → underline → strikethrough → script,
1207/// each wrapping the previous result, so the *last* applied is the outermost.
1208fn style_tags(run: &InlineRun) -> Vec<&'static str> {
1209    let mut tags = Vec::new();
1210    match run.script {
1211        Script::Sub => tags.push("subscript"),
1212        Script::Super => tags.push("superscript"),
1213        Script::Baseline => {}
1214    }
1215    if run.strike {
1216        tags.push("strikethrough");
1217    }
1218    if run.underline {
1219        tags.push("underline");
1220    }
1221    if run.italic {
1222        tags.push("italic");
1223    }
1224    if run.bold {
1225        tags.push("bold");
1226    }
1227    if run.code {
1228        tags.push("code");
1229    }
1230    tags
1231}
1232
1233/// Emit a linear chain of wrapping `tags` (outer→inner) around `inner` text. A
1234/// single tag renders inline (`<bold>x</bold>`); nested tags render block-form,
1235/// the innermost (a text child) inline — matching minidom's single-text-child
1236/// rule at each level.
1237fn emit_styled(out: &mut Out, depth: i32, tags: &[&str], inner: &str) {
1238    match tags {
1239        [] => emit_text_node(out, depth, inner),
1240        [tag] => out.push(depth, format!("<{tag}>{inner}</{tag}>")),
1241        [tag, rest @ ..] => {
1242            out.push(depth, format!("<{tag}>"));
1243            emit_styled(out, depth + 1, rest, inner);
1244            out.push(depth, format!("</{tag}>"));
1245        }
1246    }
1247}
1248
1249/// Render a [`Node::Furniture`] wrapper: the inner element with a
1250/// `<layer value="{layer}"/>` head (which forces the block form). Headings (the
1251/// HTML `<title>`, section chrome) and body text (docx comments, nav items) are
1252/// emitted with the layer token; other nodes fall back to their body rendering.
1253fn emit_furniture(out: &mut Out, depth: i32, layer: ContentLayer, inner: &Node) {
1254    let token = format!("<layer value=\"{}\"/>", layer.value());
1255    match inner {
1256        Node::Heading { level, text } => {
1257            let open = if *level <= 1 {
1258                "heading".to_string()
1259            } else {
1260                format!("heading level=\"{level}\"")
1261            };
1262            out.push(depth, format!("<{open}>"));
1263            out.push(depth + 1, token);
1264            out.push(depth + 1, escape_text(text));
1265            out.push(depth, "</heading>".to_string());
1266        }
1267        Node::Paragraph { text } => {
1268            out.push(depth, "<text>".to_string());
1269            out.push(depth + 1, token);
1270            out.push(depth + 1, escape_text(text));
1271            out.push(depth, "</text>".to_string());
1272        }
1273        // A located notes text (PPTX speaker notes: docling gives them a zero
1274        // bbox provenance): layer token first, then the location tokens.
1275        Node::Located { location, inner } => {
1276            if let Node::Paragraph { text } = &**inner {
1277                out.push(depth, "<text>".to_string());
1278                out.push(depth + 1, token);
1279                push_location(out, depth + 1, location);
1280                out.push(depth + 1, escape_text(text));
1281                out.push(depth, "</text>".to_string());
1282            } else {
1283                let mut i = 0usize;
1284                emit_nodes(out, depth, std::slice::from_ref(inner.as_ref()), &mut i, 0);
1285            }
1286        }
1287        // A furniture inline group (a mixed-formatting header/footer paragraph):
1288        // wrapped in `<text>`, with each child run carrying its own layer token
1289        // (docling stamps the layer on every text item of the group).
1290        Node::InlineGroup { runs, .. } => {
1291            out.push(depth, "<text>".to_string());
1292            for run in runs {
1293                out.push(depth + 1, token.clone());
1294                if run.is_plain() {
1295                    out.push(depth + 1, escape_text(&run.text));
1296                } else if run.formula {
1297                    out.push(
1298                        depth + 1,
1299                        format!("<formula>{}</formula>", escape_text(&run.text)),
1300                    );
1301                } else {
1302                    emit_styled(out, depth + 1, &style_tags(run), &escape_text(&run.text));
1303                }
1304            }
1305            out.push(depth, "</text>".to_string());
1306        }
1307        // A furniture picture (site-chrome logo/banner, header/footer image):
1308        // the layer token, an embedded-image `<src>` when the picture carries
1309        // pixels (docling's referenced-asset conversion skips furniture, so the
1310        // image stays a base64 data URI), then a caption that carries its own
1311        // `<href>`/`<layer>` head when the caption is a link.
1312        Node::Picture { caption, image, .. } => {
1313            let caption = caption.as_deref().filter(|c| !c.trim().is_empty());
1314            out.push(depth, "<picture>".to_string());
1315            out.push(depth + 1, token.clone());
1316            if let Some(img) = image {
1317                out.push(
1318                    depth + 1,
1319                    format!(
1320                        "<src uri=\"data:image/png;base64,{}\"/>",
1321                        crate::base64::encode(&img.data)
1322                    ),
1323                );
1324            }
1325            if let Some(c) = caption {
1326                out.push(depth + 1, "<caption>".to_string());
1327                match inline_runs(c).into_iter().next() {
1328                    Some(Run::Link { anchor, uri }) => {
1329                        out.push(depth + 2, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1330                        out.push(depth + 2, token.clone());
1331                        out.push(depth + 2, escape_text(&anchor));
1332                    }
1333                    _ => {
1334                        out.push(depth + 2, token.clone());
1335                        out.push(depth + 2, escape_text(c));
1336                    }
1337                }
1338                out.push(depth + 1, "</caption>".to_string());
1339            }
1340            out.push(depth, "</picture>".to_string());
1341        }
1342        // An invisible-layer table (a hidden spreadsheet sheet): the layer
1343        // token precedes the location/cells inside the `<table>`.
1344        Node::Table(table) => {
1345            out.push(depth, "<table>".to_string());
1346            out.push(depth + 1, token);
1347            emit_table_rows(out, depth, table);
1348            out.push(depth, "</table>".to_string());
1349        }
1350        other => {
1351            let mut i = 0usize;
1352            emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1353        }
1354    }
1355}
1356
1357/// Render a `<picture>` — with optional layout provenance and caption. Empty
1358/// (no location, no caption) collapses to `<picture></picture>`.
1359fn emit_picture(
1360    out: &mut Out,
1361    depth: i32,
1362    caption: Option<&str>,
1363    image: Option<&crate::document::PictureImage>,
1364    location: Option<&[u16; 4]>,
1365) {
1366    let caption = caption.filter(|c| !c.trim().is_empty());
1367    // An image-bearing picture carries a referenced-image `<src>` naming the
1368    // exported asset (`assets/image_{index:06}_{sha256}.png`), matching docling's
1369    // referenced-image mode. docling re-encodes every image to PNG through PIL, so
1370    // the extension is always `.png` and the content hash is over those re-encoded
1371    // bytes — not reproducible here, so we hash the source bytes and the
1372    // conformance harness canonicalizes the digest before comparing.
1373    let src = image.map(|img| {
1374        let idx = out.pic_index;
1375        out.pic_index += 1;
1376        format!("assets/image_{idx:06}_{}.png", sha256_hex(&img.data))
1377    });
1378    if location.is_none() && caption.is_none() && src.is_none() {
1379        out.push(depth, "<picture></picture>".to_string());
1380        return;
1381    }
1382    out.push(depth, "<picture>".to_string());
1383    if let Some(loc) = location {
1384        push_location(out, depth + 1, loc);
1385    }
1386    if let Some(s) = src {
1387        out.push(depth + 1, format!("<src uri=\"{}\"/>", attr_escape(&s)));
1388    }
1389    if let Some(c) = caption {
1390        emit_caption(out, depth + 1, c);
1391    }
1392    out.push(depth, "</picture>".to_string());
1393}
1394
1395/// A `<caption>` — inline when plain text, or block form with an `<href uri=…/>`
1396/// head + anchor text when the caption is a single Markdown link (docling's
1397/// linked image captions).
1398fn emit_caption(out: &mut Out, depth: i32, text: &str) {
1399    if let Some(Run::Link { anchor, uri }) = inline_runs(text).into_iter().next() {
1400        if inline_runs(text).len() == 1 {
1401            out.push(depth, "<caption>".to_string());
1402            out.push(depth + 1, format!("<href uri=\"{}\"/>", attr_escape(&uri)));
1403            out.push(depth + 1, escape_text(&anchor));
1404            out.push(depth, "</caption>".to_string());
1405            return;
1406        }
1407    }
1408    out.push(depth, format!("<caption>{}</caption>", escape_text(text)));
1409}
1410
1411/// If `text` is a single `[anchor](uri)` Markdown link, return just `anchor`;
1412/// otherwise return `text` unchanged. Used when the link's uri rides in a list
1413/// item's `<href>` head, so the content keeps only the anchor text.
1414fn strip_lone_link(text: &str) -> Cow<'_, str> {
1415    if let Some(rest) = text.strip_prefix('[') {
1416        if let Some(close) = rest.find("](") {
1417            if rest.ends_with(')') {
1418                let anchor = &rest[..close];
1419                let uri = &rest[close + 2..rest.len() - 1];
1420                if !anchor.contains(['[', ']']) && !uri.contains(['(', ')']) {
1421                    return Cow::Owned(anchor.to_string());
1422                }
1423            }
1424        }
1425    }
1426    Cow::Borrowed(text)
1427}
1428
1429/// Lowercase hex SHA-256 of `bytes` (image asset content hash).
1430fn sha256_hex(bytes: &[u8]) -> String {
1431    use sha2::{Digest, Sha256};
1432    let mut h = Sha256::new();
1433    h.update(bytes);
1434    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
1435}
1436
1437/// Render a [`Node::Located`] wrapper: the inner element with its `<location>`
1438/// tokens as the first children.
1439fn emit_located(out: &mut Out, depth: i32, location: &[u16; 4], inner: &Node) {
1440    match inner {
1441        Node::Heading { level, text } => {
1442            let open = if *level <= 1 {
1443                "heading".to_string()
1444            } else {
1445                format!("heading level=\"{level}\"")
1446            };
1447            emit_text_element(out, depth, &open, "heading", text, Some(location));
1448        }
1449        Node::Paragraph { text } => {
1450            emit_text_element(out, depth, "text", "text", text, Some(location));
1451        }
1452        Node::Picture { caption, image, .. } => {
1453            emit_picture(
1454                out,
1455                depth,
1456                caption.as_deref(),
1457                image.as_ref(),
1458                Some(location),
1459            );
1460        }
1461        Node::Table(t) => {
1462            // The wrapper's location takes precedence over any on the table.
1463            let mut t = t.clone();
1464            t.location = Some(*location);
1465            emit_table(out, depth, &t);
1466        }
1467        Node::Code { language, text, .. } => {
1468            emit_code(out, depth, language.as_deref(), text, Some(location));
1469        }
1470        // Other node kinds carry no location today — render them as-is.
1471        // (Located list items are routed to emit_list by emit_nodes so they
1472        // still group into one `<list>`.)
1473        other => {
1474            let mut i = 0usize;
1475            emit_nodes(out, depth, std::slice::from_ref(other), &mut i, 0);
1476        }
1477    }
1478}
1479
1480fn emit_list(out: &mut Out, depth: i32, nodes: &[Node], i: &mut usize, level: u8) {
1481    // The list kind follows the first item's DocLang overlay when it has one
1482    // (a docx multilevel item is a Markdown bullet but a DocLang ordered item).
1483    let ordered = match &nodes[*i] {
1484        Node::ListItem { ordered, dclx, .. } => dclx.as_ref().map_or(*ordered, |d| d.ordered),
1485        _ => false,
1486    };
1487    let open = if ordered {
1488        "<list class=\"ordered\">"
1489    } else {
1490        "<list>"
1491    };
1492    out.push(depth, open.to_string());
1493    let start = *i;
1494    let mut prev_number: Option<u64> = None;
1495    while *i < nodes.len() {
1496        match &nodes[*i] {
1497            Node::ListItem {
1498                level: l,
1499                text,
1500                marker,
1501                ordered: o,
1502                number,
1503                first_in_list,
1504                location,
1505                dclx,
1506                href,
1507                layer,
1508            } if *l == level => {
1509                // The DocLang overlay wins over the flat Markdown fields for the
1510                // list kind and marker (see `ListItemDclx`).
1511                let eff_ordered = dclx.as_ref().map_or(*o, |d| d.ordered);
1512                let eff_marker = dclx.as_ref().map_or(marker.as_ref(), |d| d.marker.as_ref());
1513                // A new sibling list at this depth closes this one (the caller
1514                // re-opens): the backend flagged a fresh list, the kind flips, or
1515                // an ordered run breaks — matching the Markdown serializer.
1516                if *i != start
1517                    && (*first_in_list
1518                        || eff_ordered != ordered
1519                        || (ordered && Some(*number) != prev_number.map(|n| n + 1)))
1520                {
1521                    break;
1522                }
1523                prev_number = Some(*number);
1524                // docling wraps a list item's content in `<text>` when a nested
1525                // list follows it *anywhere* inside the same `<list>` — its
1526                // `_list_item_has_segment_siblings` scans the parent group's
1527                // children after the item; a plain item with no later nested
1528                // list stays bare.
1529                let has_nested = {
1530                    let mut found = false;
1531                    let mut pn = Some(*number);
1532                    let mut j = *i + 1;
1533                    while let Some(Node::ListItem {
1534                        level: nl,
1535                        ordered: no,
1536                        number: nn,
1537                        first_in_list: nf,
1538                        dclx: nd,
1539                        ..
1540                    }) = nodes.get(j)
1541                    {
1542                        if *nl > level {
1543                            found = true;
1544                            break;
1545                        }
1546                        if *nl < level {
1547                            break;
1548                        }
1549                        // The same run-break rules as the main loop: a sibling
1550                        // list at this depth ends this `<list>` element.
1551                        let n_ordered = nd.as_ref().map_or(*no, |d| d.ordered);
1552                        if *nf
1553                            || n_ordered != ordered
1554                            || (ordered && Some(*nn) != pn.map(|n| n + 1))
1555                        {
1556                            break;
1557                        }
1558                        pn = Some(*nn);
1559                        j += 1;
1560                    }
1561                    found
1562                };
1563                // An enumeration marker (HTML/DOCX ordered items) rides inside
1564                // the `<ldiv>`; without one the delimiter is self-closing.
1565                match eff_marker {
1566                    Some(m) => {
1567                        out.push(depth + 1, "<ldiv>".to_string());
1568                        out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1569                        out.push(depth + 1, "</ldiv>".to_string());
1570                    }
1571                    None => out.push(depth + 1, "<ldiv/>".to_string()),
1572                }
1573                // Layout provenance (PPTX shapes): the four `<location>` tokens
1574                // follow the `<ldiv>` and precede the item's content, matching
1575                // docling's element head inside the list.
1576                if let Some(loc) = location {
1577                    push_location(out, depth + 1, loc);
1578                }
1579                match dclx {
1580                    // Structured DocLang content (equations/formatting): the runs
1581                    // render directly. The same `<text>` wrap rule as plain items
1582                    // applies — a nested list following the item wraps its
1583                    // content (docling's `_list_item_has_segment_siblings`).
1584                    Some(d) if !d.runs.is_empty() => {
1585                        if has_nested {
1586                            out.push(depth + 1, "<text>".to_string());
1587                            emit_inline_runs_body(out, depth + 2, &d.runs);
1588                            out.push(depth + 1, "</text>".to_string());
1589                        } else {
1590                            emit_inline_runs_body(out, depth + 1, &d.runs);
1591                        }
1592                    }
1593                    // A clean-text override (multilevel numbering) re-parses like
1594                    // a normal item but from the overlay's text.
1595                    Some(d) => emit_list_item_content(out, depth + 1, &d.text, has_nested),
1596                    None => {
1597                        // docling emits an `<href>` head only when the item's whole
1598                        // content is a lone link (`[anchor](uri)`); a mixed item
1599                        // (`text [anchor](uri) …`) keeps the anchor inline with no
1600                        // head. A non-body layer always rides in the head.
1601                        let stripped = strip_lone_link(text);
1602                        let eff_href = href
1603                            .as_deref()
1604                            .filter(|_| matches!(stripped, Cow::Owned(_)));
1605                        if eff_href.is_some() || layer.is_some() {
1606                            let content: &str = if eff_href.is_some() {
1607                                stripped.as_ref()
1608                            } else {
1609                                text.as_str()
1610                            };
1611                            emit_list_item_with_head(
1612                                out,
1613                                depth + 1,
1614                                content,
1615                                has_nested,
1616                                eff_href,
1617                                *layer,
1618                            );
1619                        } else {
1620                            emit_list_item_content(out, depth + 1, text, has_nested);
1621                        }
1622                    }
1623                }
1624                *i += 1;
1625            }
1626            Node::ListItem { level: l, .. } if *l > level => {
1627                emit_list(out, depth + 1, nodes, i, *l);
1628            }
1629            // An empty paragraph between two items of the *same* list run is
1630            // absorbed (docling deletes the empty text it added on close when
1631            // it reuses the ListGroup for the same numId). When the next item
1632            // starts a *new* list (fresh-list flag, kind flip, or an ordered
1633            // sequence break), no reuse happens and the empty text survives.
1634            Node::Paragraph { text }
1635                if text.is_empty()
1636                    && matches!(
1637                        nodes.get(*i + 1),
1638                        Some(Node::ListItem { level: nl, ordered: no, number: nn,
1639                                              first_in_list: nf, dclx: nd, .. })
1640                            if *nl > level
1641                                || (*nl == level
1642                                    && !*nf
1643                                    && nd.as_ref().map_or(*no, |d| d.ordered) == ordered
1644                                    && (!ordered
1645                                        || Some(*nn) == prev_number.map(|n| n + 1)))
1646                    ) =>
1647            {
1648                *i += 1;
1649            }
1650            _ => break,
1651        }
1652    }
1653    out.push(depth, "</list>".to_string());
1654}
1655
1656/// Render a list item's content after its `<ldiv/>`. docling wraps the content
1657/// in `<text>` when the item has a "segment sibling" — a nested list following
1658/// it — and otherwise emits it bare (a plain item as indented text, a formatted
1659/// one as its inline elements). (A uniformly-formatted item that docling stores
1660/// with direct formatting rather than an inline group is also wrapped, but that
1661/// backend-structural distinction isn't recoverable from the flat model.)
1662/// A list item whose head carries an `<href>` and/or `<layer>` (HTML links /
1663/// site chrome). Bare content puts the head right after the `<ldiv>` then the
1664/// anchor text; wrapped content (a `<text>` element, e.g. an item with a nested
1665/// sublist) puts the head *inside* the `<text>`.
1666fn emit_list_item_with_head(
1667    out: &mut Out,
1668    depth: i32,
1669    text: &str,
1670    has_nested: bool,
1671    href: Option<&str>,
1672    layer: Option<ContentLayer>,
1673) {
1674    let head = |out: &mut Out, d: i32| {
1675        if let Some(uri) = href {
1676            out.push(d, format!("<href uri=\"{}\"/>", attr_escape(uri)));
1677        }
1678        if let Some(l) = layer {
1679            out.push(d, format!("<layer value=\"{}\"/>", l.value()));
1680        }
1681    };
1682    if has_nested {
1683        out.push(depth, "<text>".to_string());
1684        head(out, depth + 1);
1685        emit_runs(out, depth + 1, inline_runs(text));
1686        out.push(depth, "</text>".to_string());
1687    } else {
1688        head(out, depth);
1689        emit_runs(out, depth, inline_runs(text));
1690    }
1691}
1692
1693fn emit_list_item_content(out: &mut Out, depth: i32, text: &str, has_nested: bool) {
1694    // docling models an HTML list item's inline content as an InlineGroup: each
1695    // text node / inline element becomes a separate child, links flatten to
1696    // their anchor (the href is dropped in inline scope), and the children are
1697    // rendered on their own lines. Re-parse the Markdown markers into runs and
1698    // mirror that layout.
1699    let runs = inline_runs_from_markdown(text);
1700    let single_plain = runs.len() <= 1 && runs.first().is_none_or(|r| r.is_plain());
1701    if single_plain {
1702        if has_nested {
1703            emit_text_element(out, depth, "text", "text", text, None);
1704        } else if !text.trim().is_empty() {
1705            // The original text, not the re-parsed run: an unformatted item keeps
1706            // its raw boundary whitespace (docling stores the backend's run text
1707            // verbatim, and the serializer preserves it with `<content>`).
1708            emit_text_node(out, depth, text);
1709        }
1710    } else if has_nested {
1711        emit_inline_group(out, depth, false, &runs);
1712    } else {
1713        // Bare multi-segment item: the runs render at the item's own depth, the
1714        // first indented and the rest column-0 (minidom's text-child layout).
1715        emit_inline_runs_body(out, depth, &runs);
1716    }
1717}
1718
1719fn emit_field_region(out: &mut Out, depth: i32, items: &[FieldItem]) {
1720    out.push(depth, "<field_region>".to_string());
1721    for item in items {
1722        out.push(depth + 1, "<field_item>".to_string());
1723        if let Some(m) = item.marker.as_ref().filter(|s| !s.is_empty()) {
1724            out.push(depth + 2, format!("<marker>{}</marker>", escape_text(m)));
1725        }
1726        if let Some(k) = item.key.as_ref().filter(|s| !s.is_empty()) {
1727            out.push(depth + 2, format!("<key>{}</key>", escape_text(k)));
1728        }
1729        if let Some(v) = item.value.as_ref().filter(|s| !s.is_empty()) {
1730            out.push(depth + 2, format!("<value>{}</value>", escape_text(v)));
1731        }
1732        out.push(depth + 1, "</field_item>".to_string());
1733    }
1734    out.push(depth, "</field_region>".to_string());
1735}
1736
1737#[cfg(test)]
1738mod tests {
1739    use super::*;
1740
1741    #[test]
1742    fn located_heading_emits_location_tokens_in_block_form() {
1743        let doclang = export_to_doclang(&[Node::Located {
1744            location: [44, 170, 340, 386],
1745            inner: Box::new(Node::Heading {
1746                level: 1,
1747                text: "X-Library".into(),
1748            }),
1749        }]);
1750        assert!(
1751            doclang.contains(
1752                "<heading>\n    <location value=\"44\"/>\n    <location value=\"170\"/>\n    \
1753                 <location value=\"340\"/>\n    <location value=\"386\"/>\n    X-Library\n  </heading>"
1754            ),
1755            "got:\n{doclang}"
1756        );
1757    }
1758
1759    fn code(language: Option<&str>, text: &str) -> String {
1760        export_to_doclang(&[Node::Code {
1761            language: language.map(String::from),
1762            text: text.into(),
1763            orig: None,
1764        }])
1765    }
1766
1767    #[test]
1768    fn code_with_language_emits_linguist_label_block_form() {
1769        // Fence language folds through the docling value onto the Linguist key,
1770        // forcing the block form; CDATA text glues the closing tag.
1771        assert_eq!(
1772            code(Some("python"), "print(\"Hello world!\")"),
1773            "<doclang version=\"0.7\">\n  <code>\n    <label value=\"Python\"/>\n\
1774             <![CDATA[print(\"Hello world!\")]]>  </code>\n</doclang>"
1775        );
1776        // Aliased label: bash -> Shell.
1777        assert!(code(Some("bash"), "ls -la").contains("<label value=\"Shell\"/>"));
1778    }
1779
1780    fn plain(text: &str) -> InlineRun {
1781        InlineRun {
1782            text: text.into(),
1783            ..Default::default()
1784        }
1785    }
1786    fn bold(text: &str) -> InlineRun {
1787        InlineRun {
1788            text: text.into(),
1789            bold: true,
1790            ..Default::default()
1791        }
1792    }
1793    fn ig(unwrapped: bool, runs: Vec<InlineRun>) -> String {
1794        let body = export_to_doclang(&[Node::InlineGroup {
1795            unwrapped,
1796            runs,
1797            md_text: String::new(),
1798        }]);
1799        // strip the <doclang> envelope for readable assertions
1800        body.trim_start_matches("<doclang version=\"0.7\">\n")
1801            .trim_end_matches("\n</doclang>")
1802            .to_string()
1803    }
1804
1805    #[test]
1806    fn inline_group_matches_reference_layout() {
1807        // wrapped, mixed: first text indented, post-element text at col 0.
1808        assert_eq!(
1809            ig(
1810                false,
1811                vec![plain("This is a"), bold("bold"), plain("example")]
1812            ),
1813            "  <text>\n    This is a\n    <bold>bold</bold>\nexample\n  </text>"
1814        );
1815        // unwrapped, mixed: text at col 0, elements at depth 1.
1816        assert_eq!(
1817            ig(
1818                true,
1819                vec![
1820                    plain("aa"),
1821                    bold("bb"),
1822                    plain("cc"),
1823                    bold("dd"),
1824                    plain("ee")
1825                ]
1826            ),
1827            "aa\n  <bold>bb</bold>\ncc\n  <bold>dd</bold>\nee"
1828        );
1829        // wrapped, all-plain: single text node with trailing newline.
1830        assert_eq!(
1831            ig(false, vec![plain("aa"), plain("bb")]),
1832            "  <text>aa\nbb\n</text>"
1833        );
1834        assert_eq!(ig(false, vec![plain("aa")]), "  <text>aa\n</text>");
1835        // wrapped, single element.
1836        assert_eq!(
1837            ig(false, vec![bold("bb")]),
1838            "  <text>\n    <bold>bb</bold>\n  </text>"
1839        );
1840    }
1841
1842    #[test]
1843    fn nested_styles_wrap_outermost_last_applied() {
1844        let bi = InlineRun {
1845            text: "bi".into(),
1846            bold: true,
1847            italic: true,
1848            ..Default::default()
1849        };
1850        // italic (applied after bold) is outermost; block form.
1851        assert_eq!(
1852            ig(true, vec![bi]),
1853            "  <italic>\n    <bold>bi</bold>\n  </italic>"
1854        );
1855        let sub = InlineRun {
1856            text: "2".into(),
1857            script: Script::Sub,
1858            ..Default::default()
1859        };
1860        assert_eq!(ig(true, vec![sub]), "  <subscript>2</subscript>");
1861    }
1862
1863    #[test]
1864    fn furniture_heading_gets_layer_head() {
1865        let out = export_to_doclang(&[Node::Furniture {
1866            layer: ContentLayer::Furniture,
1867            inner: Box::new(Node::Heading {
1868                level: 1,
1869                text: "Anchor Links Test".into(),
1870            }),
1871        }]);
1872        assert_eq!(
1873            out,
1874            "<doclang version=\"0.7\">\n  <heading>\n    <layer value=\"furniture\"/>\n    Anchor Links Test\n  </heading>\n</doclang>"
1875        );
1876    }
1877
1878    #[test]
1879    fn text_dump_reproduces_minidom_per_line_layout() {
1880        // A plain-text dump: the first record indents, later plain records sit at
1881        // column 0, a line with `"`/`&` becomes CDATA (with the next child's indent
1882        // glued on as trailing whitespace), a `*`…`*` span becomes per-line
1883        // `<italic>`, and an underscore rule collapses to ten underscores.
1884        let text = "PATN\nWKU 1\nPAL K. \"Determination\"\nfollow-up\n*Note A\n_______________\nNote B*\nEND";
1885        let out = export_to_doclang(&[Node::TextDump(text.into())]);
1886        let expected = "<doclang version=\"0.7\">\n  \
1887             <text>\n    \
1888             PATN\nWKU 1\n\
1889             <![CDATA[PAL K. \"Determination\"]]>    \n\
1890             follow-up\n    \
1891             <italic>Note A</italic>\n    \
1892             <italic>__________</italic>\n    \
1893             <italic>Note B</italic>\n\
1894             END\n  \
1895             </text>\n</doclang>";
1896        assert_eq!(out, expected, "got:\n{out}");
1897    }
1898
1899    #[test]
1900    fn code_without_language_stays_inline_and_unlabeled() {
1901        assert_eq!(
1902            code(None, "print(\"Hi!\")"),
1903            "<doclang version=\"0.7\">\n  <code><![CDATA[print(\"Hi!\")]]></code>\n</doclang>"
1904        );
1905        // Unknown fence language: no label, still inline.
1906        assert!(!code(Some("brainfuck"), "+++.").contains("<label"));
1907    }
1908}