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