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