Skip to main content

docling_core/
doclang.rs

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