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