Skip to main content

docling_core/
json.rs

1//! Export a [`DoclingDocument`] to docling-core's native JSON wire format
2//! (`DoclingDocument` schema v1.10.0) — the same shape `export_to_dict()` /
3//! `save_as_json()` produce in Python docling, and the inverse of the
4//! JSON-docling reader.
5//!
6//! The crate's [`Node`] model bakes Markdown escaping (and inline markers) into
7//! its text, whereas docling stores raw text and escapes at render time. We
8//! therefore *un-escape* on the way out so a docling-core round-trip
9//! (`load_from_json().export_to_markdown()`) reproduces the same Markdown.
10
11use serde_json::{json, Value};
12
13use crate::document::{CaptionParent, ContentLayer, DoclingDocument, Node, Table};
14
15const SCHEMA_VERSION: &str = "1.10.0";
16
17/// docling-core's `CodeLanguageLabel` values (anything else serializes as
18/// `unknown`, which the model requires for code items).
19const CODE_LANGUAGES: &[&str] = &[
20    "Ada",
21    "Awk",
22    "Bash",
23    "bc",
24    "C",
25    "C#",
26    "C++",
27    "CMake",
28    "COBOL",
29    "CSS",
30    "Ceylon",
31    "Clojure",
32    "Crystal",
33    "Cuda",
34    "Cython",
35    "D",
36    "Dart",
37    "dc",
38    "Dockerfile",
39    "DocLang",
40    "Elixir",
41    "Erlang",
42    "FORTRAN",
43    "Forth",
44    "Go",
45    "HTML",
46    "Haskell",
47    "Haxe",
48    "Java",
49    "JavaScript",
50    "JSON",
51    "Julia",
52    "Kotlin",
53    "Latex",
54    "Lisp",
55    "Lua",
56    "Matlab",
57    "MoonScript",
58    "Nim",
59    "OCaml",
60    "ObjectiveC",
61    "Octave",
62    "PHP",
63    "Pascal",
64    "Perl",
65    "Prolog",
66    "Python",
67    "Racket",
68    "Ruby",
69    "Rust",
70    "SML",
71    "SQL",
72    "Scala",
73    "Scheme",
74    "Swift",
75    "Tikz",
76    "TypeScript",
77    "VisualBasic",
78    "XML",
79    "YAML",
80];
81
82/// Map a fence language to docling's `CodeLanguageLabel` (case-insensitive), else
83/// `unknown`.
84/// docling's `CodeLanguageLabel` for a language name (`unknown` when it is
85/// not one docling knows) — the mapping the JSON `code_language` field uses,
86/// for a backend that wants to test a hint before storing it.
87pub fn code_language_label(lang: &str) -> &'static str {
88    code_language(Some(lang))
89}
90
91pub(crate) fn code_language(lang: Option<&str>) -> &'static str {
92    match lang {
93        Some(l) => CODE_LANGUAGES
94            .iter()
95            .find(|c| c.eq_ignore_ascii_case(l))
96            .copied()
97            .unwrap_or("unknown"),
98        None => "unknown",
99    }
100}
101
102/// An item's exact provenance, written verbatim (see [`Builder::prov_json`]):
103/// a [`Node::Prov`] wrapper's top-left page box, or a tree item's
104/// [`TreeProv`](crate::tree::TreeProv).
105#[derive(Clone, Copy)]
106struct ExactProv {
107    page_no: usize,
108    bbox: [f64; 4],
109    bottom_left: bool,
110    charspan: [usize; 2],
111}
112
113impl From<&crate::tree::TreeProv> for ExactProv {
114    fn from(p: &crate::tree::TreeProv) -> Self {
115        ExactProv {
116            page_no: p.page_no,
117            bbox: p.bbox,
118            bottom_left: p.bottom_left,
119            charspan: p.charspan,
120        }
121    }
122}
123
124/// docling-core's `_clamp_provenance_bboxes_to_pages`: each `prov` box of
125/// every item on a known page is clamped to `[0, width] × [0, height]`
126/// (whatever its `coord_origin` — docling clamps the stored numbers), and a
127/// table whose provenance sits on one page has its cell boxes clamped too.
128fn clamp_boxes_to_pages(out: &mut Value, pages: &[(usize, f64, f64)]) {
129    if pages.is_empty() {
130        return;
131    }
132    // First entry per page wins, as the linear `find` this replaces did; a
133    // scan per box was quadratic on long documents (items × pages).
134    let mut sizes: std::collections::HashMap<usize, (f64, f64)> =
135        std::collections::HashMap::with_capacity(pages.len());
136    for &(p, w, h) in pages {
137        sizes.entry(p).or_insert((w, h));
138    }
139    let size = |page_no: &Value| -> Option<(f64, f64)> {
140        let n = page_no.as_u64()? as usize;
141        sizes.get(&n).copied()
142    };
143    let r2 = |v: f64| (v * 100.0).round() / 100.0;
144    let clamp_bbox = |bbox: &mut Value, (w, h): (f64, f64)| {
145        for (key, hi) in [("l", w), ("r", w), ("t", h), ("b", h)] {
146            if let Some(v) = bbox.get(key).and_then(Value::as_f64) {
147                bbox[key] = json!(r2(v.clamp(0.0, hi.max(0.0))));
148            }
149        }
150    };
151    for bucket in [
152        "texts",
153        "pictures",
154        "tables",
155        "key_value_items",
156        "form_items",
157        "field_regions",
158        "field_items",
159    ] {
160        let Some(items) = out.get_mut(bucket).and_then(Value::as_array_mut) else {
161            continue;
162        };
163        for item in items {
164            let mut table_page: Option<Option<(f64, f64)>> = None;
165            if let Some(provs) = item.get_mut("prov").and_then(Value::as_array_mut) {
166                let mut page_nos: Vec<u64> = Vec::new();
167                for prov in provs.iter_mut() {
168                    if let Some(n) = prov.get("page_no").and_then(Value::as_u64) {
169                        page_nos.push(n);
170                    }
171                    let Some(sz) = prov.get("page_no").and_then(size) else {
172                        continue;
173                    };
174                    if let Some(bbox) = prov.get_mut("bbox") {
175                        clamp_bbox(bbox, sz);
176                    }
177                }
178                page_nos.sort_unstable();
179                page_nos.dedup();
180                if let [only] = page_nos[..] {
181                    table_page = Some(size(&json!(only)));
182                }
183            }
184            // A table's cells (and the `grid` docling derives from them).
185            if let (Some(Some(sz)), Some(data)) = (table_page, item.get_mut("data")) {
186                for key in ["table_cells", "grid"] {
187                    let Some(rows) = data.get_mut(key).and_then(Value::as_array_mut) else {
188                        continue;
189                    };
190                    for entry in rows.iter_mut() {
191                        let cells: Vec<&mut Value> = match entry {
192                            Value::Array(row) => row.iter_mut().collect(),
193                            other => vec![other],
194                        };
195                        for cell in cells {
196                            if let Some(bbox) = cell.get_mut("bbox").filter(|b| b.is_object()) {
197                                clamp_bbox(bbox, sz);
198                            }
199                        }
200                    }
201                }
202            }
203        }
204    }
205}
206
207/// docling-core's `Formatting` model, every field written.
208fn formatting_json(f: &crate::tree::Formatting) -> Value {
209    json!({
210        "bold": f.bold,
211        "italic": f.italic,
212        "underline": f.underline,
213        "strikethrough": f.strikethrough,
214        "script": match f.script {
215            crate::Script::Baseline => "baseline",
216            crate::Script::Sub => "sub",
217            crate::Script::Super => "super",
218        },
219    })
220}
221
222/// docling's `TrackSource` entry: `kind`, the two offsets, then the optional
223/// `identifier` / `voice` (dropped when `None`, as `exclude_none` does).
224fn track_json(t: &crate::tree::TreeTrack) -> Value {
225    let mut m = serde_json::Map::new();
226    m.insert("kind".into(), json!("track"));
227    m.insert("start_time".into(), json!(t.start_time));
228    m.insert("end_time".into(), json!(t.end_time));
229    if let Some(id) = &t.identifier {
230        m.insert("identifier".into(), json!(id));
231    }
232    if let Some(v) = &t.voice {
233        m.insert("voice".into(), json!(v));
234    }
235    Value::Object(m)
236}
237
238/// docling-core's `ImageRef` (pydantic field order: mimetype, dpi, size —
239/// as floats — uri), with the bytes inlined as a `data:` URI — a PNG, like
240/// `ImageRef.from_pil`'s (`pixel_digest::docling_data_uri`).
241fn image_ref_json(img: &crate::PictureImage) -> Value {
242    let (mimetype, uri) = crate::pixel_digest::docling_data_uri(img);
243    json!({
244        "mimetype": mimetype,
245        "dpi": img.dpi,
246        "size": { "width": img.width as f64, "height": img.height as f64 },
247        "uri": uri,
248    })
249}
250
251/// Build the docling-core JSON object for `doc`.
252pub fn to_json(doc: &DoclingDocument) -> Value {
253    build_json(doc, false)
254}
255
256/// [`to_json`] plus the note calls docling's model cannot express: a text
257/// item calling footnotes carries `"_notes": [[offset, text], …]`, a
258/// footnote item that is such a note's body `"_note_body": true` (#538).
259/// The Pandoc writer reads these; nothing is ever exported with them.
260pub(crate) fn to_json_with_notes(doc: &DoclingDocument) -> Value {
261    build_json(doc, true)
262}
263
264fn build_json(doc: &DoclingDocument, note_keys: bool) -> Value {
265    let mut b = Builder {
266        note_keys,
267        ..Builder::default()
268    };
269    // A backend that built docling's item tree ([`crate::tree`]) has already
270    // decided every parent, child and creation index; serialize that. The
271    // flat nodes are for the other serializers.
272    let body = match &doc.tree {
273        Some(tree) => {
274            // The page map still comes from the flat stream's markers (a
275            // PPTX slide's EMU size): the tree holds items, not pages.
276            for n in &doc.nodes {
277                if let Node::PageInfo {
278                    page_no,
279                    width,
280                    height,
281                } = n
282                {
283                    if *page_no > 0 {
284                        b.pages.push((*page_no, *width as f64, *height as f64));
285                    }
286                }
287            }
288            // docling-core's `validate_misplaced_list_items` runs on every
289            // document it serializes; apply it the same way (keeping the
290            // items' children, which docling-core drops — #586).
291            let mut tree = tree.clone();
292            tree.wrap_misplaced_list_items();
293            b.write_tree(&tree)
294        }
295        None => b.walk_into(&doc.nodes, "#/body"),
296    };
297    b.link_comments();
298
299    let pages = b
300        .pages
301        .iter()
302        .map(|(n, w, h)| {
303            let r2 = |v: f64| (v * 100.0).round() / 100.0;
304            // docling-core's `PageItem` field order: size, image, page_no
305            // (#520 — the image only when page images were generated).
306            let mut page = serde_json::Map::new();
307            page.insert("size".into(), json!({ "width": r2(*w), "height": r2(*h) }));
308            if let Some(img) = doc.page_images.get(n) {
309                page.insert("image".into(), image_ref_json(img));
310            }
311            page.insert("page_no".into(), json!(n));
312            (n.to_string(), Value::Object(page))
313        })
314        .collect::<serde_json::Map<String, Value>>();
315    // Built with [`object`], not `json!`: the macro deep-copies every
316    // interpolated value (`to_value`), and copying the item arrays briefly
317    // doubled the export's peak.
318    let mut out = object([
319        ("schema_name", "DoclingDocument".into()),
320        ("version", SCHEMA_VERSION.into()),
321        ("name", doc.name.as_str().into()),
322        (
323            "origin",
324            json!({
325                "mimetype": "text/plain",
326                "binary_hash": fnv1a(&doc.name),
327                "filename": doc.name,
328            }),
329        ),
330        (
331            "furniture",
332            json!({
333                "self_ref": "#/furniture",
334                "children": [],
335                "content_layer": "furniture",
336                "name": "_root_",
337                "label": "unspecified",
338            }),
339        ),
340        (
341            "body",
342            object([
343                ("self_ref", "#/body".into()),
344                ("children", Value::Array(body)),
345                ("content_layer", "body".into()),
346                ("name", "_root_".into()),
347                ("label", "unspecified".into()),
348            ]),
349        ),
350        ("groups", Value::Array(std::mem::take(&mut b.groups))),
351        ("texts", Value::Array(std::mem::take(&mut b.texts))),
352        ("pictures", Value::Array(std::mem::take(&mut b.pictures))),
353        ("tables", Value::Array(std::mem::take(&mut b.tables))),
354        (
355            "key_value_items",
356            Value::Array(std::mem::take(&mut b.key_value_items)),
357        ),
358        ("form_items", Value::Array(Vec::new())),
359        ("pages", Value::Object(pages)),
360    ]);
361
362    // docling-core's `validate_document` — a pydantic `model_validator` every
363    // `DoclingDocument` passes through when a `ConversionResult` (or a
364    // serializer) is built around it — clamps every provenance box, and a
365    // table's cell boxes, into its page's bounds in place
366    // (`_clamp_provenance_bboxes_to_pages`). The JSON docling writes therefore
367    // carries the clamped boxes: a spreadsheet region whose page was sized
368    // `right − left` × `bottom − top` loses its offset, a slide shape hanging
369    // off the slide edge is cut at it. Reproduce that on the finished items.
370    clamp_boxes_to_pages(&mut out, &b.pages);
371
372    // docling only emits `field_regions` / `field_items` when a document has
373    // form fields, and places them just before `pages`. Insert them in that slot
374    // (re-appending `pages` afterwards, since `preserve_order` keeps insertion
375    // order) so non-KVP documents' JSON is byte-identical to before.
376    if !b.field_regions.is_empty() {
377        if let Some(obj) = out.as_object_mut() {
378            let pages = obj.remove("pages");
379            obj.insert("field_regions".into(), Value::Array(b.field_regions));
380            obj.insert("field_items".into(), Value::Array(b.field_items));
381            if let Some(pages) = pages {
382                obj.insert("pages".into(), pages);
383            }
384        }
385    }
386    out
387}
388
389/// A DocumentPictureClassifier's predictions as the picture's `meta` — they
390/// land twice, exactly like docling 2.x writes them: the newer
391/// `meta.classification` field (pydantic field order: confidence, created_by,
392/// class_name) and the deprecated-but-still-emitted `classification`
393/// annotation, carried here under an `annotations` key that `add_picture`
394/// lifts onto the item.
395fn classification_meta(classes: &[crate::PictureClass]) -> Value {
396    json!({
397        "classification": {
398            "predictions": classes.iter().map(|c| json!({
399                "confidence": c.confidence as f64,
400                "created_by": "DocumentPictureClassifier",
401                "class_name": c.class_name,
402            })).collect::<Vec<_>>(),
403        },
404        "annotations": [{
405            "kind": "classification",
406            "provenance": "DocumentPictureClassifier",
407            "predicted_classes": classes.iter().map(|c| json!({
408                "class_name": c.class_name,
409                "confidence": c.confidence as f64,
410            })).collect::<Vec<_>>(),
411        }],
412    })
413}
414
415/// docling's `TableData` for a table: `table_cells`, `num_rows`/`num_cols`
416/// and the `grid` that repeats each cell at every position it covers. Shared
417/// by table items and a chart picture's `meta.tabular_chart.chart_data`.
418/// One table cell as docling's `TableCell` JSON: the eleven fields in
419/// pydantic order, `bbox` appended when the cell carries one. Built straight
420/// into a `Map` sized for the entries — the table grid repeats every cell
421/// object once per spanned slot, so on a table-heavy document (a patent's
422/// claims tables, an EBCDIC dump) this constructor and its clones *are* the
423/// JSON export's cost; the `json!` macro built the same object through a
424/// growing map with a rehash per doubling.
425#[allow(clippy::too_many_arguments)]
426fn cell_value(
427    row_span: usize,
428    col_span: usize,
429    start_row: usize,
430    end_row: usize,
431    start_col: usize,
432    end_col: usize,
433    text: String,
434    column_header: bool,
435    row_header: bool,
436    row_section: bool,
437    bbox: Option<[f32; 4]>,
438) -> Value {
439    let mut m = serde_json::Map::with_capacity(12);
440    m.insert("row_span".into(), row_span.into());
441    m.insert("col_span".into(), col_span.into());
442    m.insert("start_row_offset_idx".into(), start_row.into());
443    m.insert("end_row_offset_idx".into(), end_row.into());
444    m.insert("start_col_offset_idx".into(), start_col.into());
445    m.insert("end_col_offset_idx".into(), end_col.into());
446    m.insert("text".into(), Value::String(text));
447    m.insert("column_header".into(), column_header.into());
448    m.insert("row_header".into(), row_header.into());
449    m.insert("row_section".into(), row_section.into());
450    m.insert("fillable".into(), false.into());
451    if let Some(b) = bbox {
452        m.insert(
453            "bbox".into(),
454            json!({
455                "l": b[0], "t": b[1], "r": b[2], "b": b[3],
456                "coord_origin": "TOPLEFT",
457            }),
458        );
459    }
460    Value::Object(m)
461}
462
463/// `TableData` from a table whose cell text is Markdown-flavoured (the flat
464/// nodes: escapes to undo, GFM hard-break markers to strip).
465fn table_data(t: &Table) -> Value {
466    table_data_with(t, false)
467}
468
469/// `TableData`; with `raw` the cell text is docling's own raw cell text (a
470/// backend-built tree) and is written verbatim.
471fn table_data_with(t: &Table, raw: bool) -> Value {
472    let cell_text = |s: &str| {
473        if raw {
474            s.to_string()
475        } else {
476            unescape_text(&crate::markdown::strip_hard_breaks(s))
477        }
478    };
479    let mut num_rows = t.rows.len();
480    let mut num_cols = t.rows.iter().map(Vec::len).max().unwrap_or(0);
481    // Rectangular `rows` are the grid, and cells reaching past it are
482    // clipped — docling's own `TableData.grid` on a USPTO table whose
483    // replicated cells outrun `num_cols`, or an HTML rowspan past the last
484    // row. Ragged rows are no grid at all: a backend that compacts them
485    // (xlsx `skip_empty_cells`, #271) keeps the true offsets in first-class
486    // cells, so there the grid spans every cell's extent and the omitted
487    // positions come back as docling's empty filler cells.
488    let ragged = t.rows.iter().any(|r| r.len() != num_cols);
489    if let Some(first_class) = t.cells.as_ref().filter(|c| ragged && !c.is_empty()) {
490        for c in first_class {
491            num_rows = num_rows.max(c.start_row + c.row_span);
492            num_cols = num_cols.max(c.start_col + c.col_span);
493        }
494    }
495    let mut grid = Vec::with_capacity(num_rows);
496    let mut cells = Vec::new();
497    // Grid slot → index into `cells` (the anchor cell covering it). A flat
498    // row-major table instead of a `HashMap<(r, c), Value>` of clones: the
499    // grid is filled from it with one clone per slot, and nothing is hashed.
500    let mut slot: Vec<Option<usize>> = vec![None; num_rows * num_cols];
501    if let Some(first_class) = t.cells.as_ref().filter(|c| !c.is_empty()) {
502        for c in first_class {
503            let idx = cells.len();
504            cells.push(cell_value(
505                c.row_span,
506                c.col_span,
507                c.start_row,
508                c.start_row + c.row_span,
509                c.start_col,
510                c.start_col + c.col_span,
511                cell_text(&c.text),
512                c.column_header,
513                c.row_header,
514                c.row_section,
515                c.bbox,
516            ));
517            for r in c.start_row..(c.start_row + c.row_span).min(num_rows) {
518                for k in c.start_col..(c.start_col + c.col_span).min(num_cols) {
519                    slot[r * num_cols + k] = Some(idx);
520                }
521            }
522        }
523        for r in 0..num_rows {
524            let mut grid_row = Vec::with_capacity(num_cols);
525            for c in 0..num_cols {
526                grid_row.push(match slot[r * num_cols + c] {
527                    Some(i) => cells[i].clone(),
528                    None => cell_value(
529                        1,
530                        1,
531                        r,
532                        r + 1,
533                        c,
534                        c + 1,
535                        String::new(),
536                        false,
537                        false,
538                        false,
539                        None,
540                    ),
541                });
542            }
543            grid.push(grid_row);
544        }
545    } else {
546        let s = t.structure.as_ref();
547        let flag = |grid: Option<&Vec<Vec<bool>>>, r: usize, c: usize| -> bool {
548            grid.and_then(|g| g.get(r))
549                .and_then(|row| row.get(c))
550                .copied()
551                .unwrap_or(false)
552        };
553        let anchor_of = |r: usize, c: usize| -> (usize, usize) {
554            let (mut r0, mut c0) = (r, c);
555            while c0 > 0 && flag(s.map(|s| &s.col_continuation), r, c0) {
556                c0 -= 1;
557            }
558            while r0 > 0 && flag(s.map(|s| &s.row_continuation), r0, c0) {
559                r0 -= 1;
560            }
561            (r0, c0)
562        };
563        // Each slot's anchor, computed once; the anchor's extent is the
564        // farthest slot that resolves to it.
565        let anchors: Vec<(usize, usize)> = (0..num_rows)
566            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
567            .map(|(r, c)| anchor_of(r, c))
568            .collect();
569        let mut extent: Vec<(usize, usize)> = (0..num_rows)
570            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
571            .collect();
572        for (i, &(ar, ac)) in anchors.iter().enumerate() {
573            let (r, c) = (i / num_cols.max(1), i % num_cols.max(1));
574            let e = &mut extent[ar * num_cols + ac];
575            e.0 = e.0.max(r);
576            e.1 = e.1.max(c);
577        }
578        for (r, row) in t.rows.iter().enumerate() {
579            let mut grid_row = Vec::with_capacity(num_cols);
580            for c in 0..num_cols {
581                let (ar, ac) = anchors[r * num_cols + c];
582                if (ar, ac) == (r, c) {
583                    let (er, ec) = extent[r * num_cols + c];
584                    let text = row.get(c).map(|s| cell_text(s)).unwrap_or_default();
585                    let column_header = match s.filter(|s| !s.col_header.is_empty()) {
586                        Some(s) => flag(Some(&s.col_header), r, c),
587                        None => r == 0,
588                    };
589                    slot[r * num_cols + c] = Some(cells.len());
590                    cells.push(cell_value(
591                        er - r + 1,
592                        ec - c + 1,
593                        r,
594                        er + 1,
595                        c,
596                        ec + 1,
597                        text,
598                        column_header,
599                        flag(s.map(|s| &s.row_header), r, c),
600                        false,
601                        None,
602                    ));
603                }
604                grid_row.push(match slot[ar * num_cols + ac] {
605                    Some(i) => cells[i].clone(),
606                    None => Value::Null,
607                });
608            }
609            grid.push(grid_row);
610        }
611    }
612    json!({
613        "table_cells": cells,
614        "num_rows": num_rows,
615        "num_cols": num_cols,
616        "orientation": "rot_0",
617        "grid": grid,
618    })
619}
620
621/// A one-entry docling `prov` array: `page_no`, `bbox` (`l t r b` +
622/// `coord_origin`) and `charspan`, in pydantic field order.
623fn prov_value(page_no: usize, [l, t, r, b]: [f64; 4], origin: &str, charspan: [usize; 2]) -> Value {
624    let bbox = object([
625        ("l", l.into()),
626        ("t", t.into()),
627        ("r", r.into()),
628        ("b", b.into()),
629        ("coord_origin", origin.into()),
630    ]);
631    let span = Value::Array(vec![charspan[0].into(), charspan[1].into()]);
632    Value::Array(vec![object([
633        ("page_no", page_no.into()),
634        ("bbox", bbox),
635        ("charspan", span),
636    ])])
637}
638
639#[derive(Default)]
640struct Builder {
641    /// Write the internal `_notes` / `_note_body` keys of tree text items
642    /// ([`crate::tree::TreeItem::notes`]) — for the Pandoc writer only
643    /// ([`to_json_with_notes`]); docling's JSON has no such fields.
644    note_keys: bool,
645    texts: Vec<Value>,
646    groups: Vec<Value>,
647    tables: Vec<Value>,
648    pictures: Vec<Value>,
649    field_regions: Vec<Value>,
650    field_items: Vec<Value>,
651    key_value_items: Vec<Value>,
652    /// Pages seen so far (`page_no`, width, height in points) — from the
653    /// [`Node::PageInfo`] markers the PDF paths emit; empty for every other
654    /// backend, which keeps their JSON byte-identical (`"pages": {}`, no prov).
655    pages: Vec<(usize, f64, f64)>,
656    /// The page the walk is currently on (0 before the first marker).
657    cur_page: usize,
658    cur_w: f64,
659    cur_h: f64,
660    /// The enclosing [`Node::Located`] wrapper's 0–511 grid box, waiting to be
661    /// consumed as the next item's provenance.
662    pending_loc: Option<[u16; 4]>,
663    /// The enclosing [`Node::Prov`] wrapper's (or the tree item's) exact
664    /// provenance, which takes precedence over the grid box.
665    pending_exact: Option<ExactProv>,
666    /// The enclosing [`Node::Track`]'s timing, for the text item it wraps
667    /// (#614).
668    pending_track: Option<(crate::tree::TreeTrack, String)>,
669    /// `$ref`s an item wants placed in its parent's `children` *before* its
670    /// own — a chart's caption item, which docling's office backends add to
671    /// the container ahead of the picture that references it.
672    pending_siblings: Vec<Value>,
673    /// `$ref`s an item wants placed in its parent's `children` right *after*
674    /// its own — an HTML `<figure>`-wrapped table's caption
675    /// ([`CaptionParent::ContainerAfter`]).
676    pending_after: Vec<Value>,
677    /// Caption `$ref`s that hang off `#/body` while their item sits deeper
678    /// ([`CaptionParent::Body`]): docling appends them to the body's children
679    /// as they are created, so they follow the top-level item being walked.
680    pending_body: Vec<Value>,
681    /// What each [`Node::CommentSection`] is referenced by, in document order —
682    /// its group `$ref`, or its note text's when the section says so. The index
683    /// is what a [`Node::Commented`] annotation carries.
684    comment_groups: Vec<String>,
685    /// The last `comment_section` group emitted, as `(name, self_ref)`: a
686    /// following section of the same name adds its note to that group rather
687    /// than opening another — one `comment-{sheet}-{cell}` group holds every
688    /// message of a threaded comment (docling#4353).
689    last_comment_section: Option<(String, String)>,
690    /// Annotated items awaiting their refs: comments are usually emitted
691    /// *after* the body they annotate (docx appends them), so the link is
692    /// patched in once the whole document has been walked.
693    pending_comments: Vec<(String, Vec<usize>)>,
694}
695
696impl Builder {
697    /// Consume the pending location (if any) into a docling `prov` array: the
698    /// 0-511 grid denormalized against the current page into BOTTOMLEFT
699    /// points, rounded to 2 decimals like docling's own export. `char_len` is
700    /// the item's text length in characters (0 for tables and pictures, whose
701    /// charspan docling emits as `[0, 0]`).
702    fn take_prov(&mut self, char_len: usize) -> Value {
703        let prov = self.prov_json(char_len, false);
704        self.pending_exact = None;
705        self.pending_loc = None;
706        prov
707    }
708
709    /// The pending provenance without consuming it. An exact [`Node::Prov`]
710    /// box wins over the grid; its own `charspan` is used unless
711    /// `span_over_text` asks for `[0, char_len]` (a chart caption's span
712    /// covers the caption text where the chart's is `[0, 0]`).
713    fn prov_json(&self, char_len: usize, span_over_text: bool) -> Value {
714        let r2 = |v: f64| (v * 100.0).round() / 100.0;
715        if let Some(ExactProv {
716            page_no,
717            bbox: [l, t, r, b],
718            bottom_left,
719            charspan,
720        }) = self.pending_exact
721        {
722            let charspan = if span_over_text {
723                [0, char_len]
724            } else {
725                charspan
726            };
727            return prov_value(
728                page_no,
729                [r2(l), r2(t), r2(r), r2(b)],
730                if bottom_left { "BOTTOMLEFT" } else { "TOPLEFT" },
731                charspan,
732            );
733        }
734        let Some([x0, y0, x1, y1]) = self.pending_loc else {
735            return json!([]);
736        };
737        // An all-zero grid box is the sentinel for "this item has no geometry"
738        // (a slide's speaker notes, say). docling writes a zero bbox for it,
739        // not a box spanning the page, which is what denormalizing would give.
740        if [x0, y0, x1, y1] == [0, 0, 0, 0] {
741            return prov_value(self.cur_page, [0.0; 4], "BOTTOMLEFT", [0, char_len]);
742        }
743        prov_value(
744            self.cur_page,
745            [
746                r2(x0 as f64 * self.cur_w / 512.0),
747                r2(self.cur_h - y0 as f64 * self.cur_h / 512.0),
748                r2(x1 as f64 * self.cur_w / 512.0),
749                r2(self.cur_h - y1 as f64 * self.cur_h / 512.0),
750            ],
751            "BOTTOMLEFT",
752            [0, char_len],
753        )
754    }
755
756    /// Adopt a node's own location field (tables, formulas, list items carry
757    /// one instead of a [`Node::Located`] wrapper) when no wrapper is pending.
758    fn adopt_loc(&mut self, loc: Option<[u16; 4]>) {
759        if self.pending_loc.is_none() && self.cur_page > 0 {
760            self.pending_loc = loc;
761        }
762    }
763
764    /// Resolve the [`Node::Commented`] annotations collected during the walk
765    /// into docling's `comments: [{"$ref": "#/groups/N"}]` key on the annotated
766    /// item. It has to run after the whole walk: docx appends its comment
767    /// bodies, so the groups they live in are usually allocated *later* than
768    /// the paragraphs pointing at them. docling emits the key between `prov`
769    /// and `orig`, so insert it in place rather than appending (serde_json runs
770    /// with `preserve_order`, i.e. key order is output order).
771    fn link_comments(&mut self) {
772        let refs: Vec<(String, Vec<Value>)> = std::mem::take(&mut self.pending_comments)
773            .into_iter()
774            .map(|(item, comments)| {
775                let refs = comments
776                    .iter()
777                    .filter_map(|i| self.comment_groups.get(*i))
778                    .map(ref_value)
779                    .collect();
780                (item, refs)
781            })
782            .collect();
783        for (item, comment_refs) in refs {
784            if comment_refs.is_empty() {
785                continue;
786            }
787            let Some(target) = self.item_mut(&item) else {
788                continue;
789            };
790            let Some(obj) = target.as_object_mut() else {
791                continue;
792            };
793            let tail: Vec<(String, Value)> = obj
794                .iter()
795                .skip_while(|(k, _)| k.as_str() != "prov")
796                .skip(1)
797                .map(|(k, v)| (k.clone(), v.clone()))
798                .collect();
799            for (k, _) in &tail {
800                obj.shift_remove(k);
801            }
802            obj.insert("comments".into(), Value::Array(comment_refs));
803            for (k, v) in tail {
804                obj.insert(k, v);
805            }
806        }
807    }
808
809    /// The stored JSON object a `#/texts/N`-style self-ref points at.
810    fn item_mut(&mut self, self_ref: &str) -> Option<&mut Value> {
811        let idx = ref_index(self_ref)?;
812        let bucket = if self_ref.starts_with("#/texts/") {
813            &mut self.texts
814        } else if self_ref.starts_with("#/tables/") {
815            &mut self.tables
816        } else if self_ref.starts_with("#/pictures/") {
817            &mut self.pictures
818        } else if self_ref.starts_with("#/groups/") {
819            &mut self.groups
820        } else {
821            return None;
822        };
823        bucket.get_mut(idx)
824    }
825
826    /// Serialize a backend-built [`ItemTree`](crate::tree::ItemTree): every
827    /// item in creation order into its bucket, with the parent / children /
828    /// layer the tree recorded. Returns the body's `children` refs.
829    fn write_tree(&mut self, tree: &crate::tree::ItemTree) -> Vec<Value> {
830        use crate::tree::TreeKind;
831        // Every item's `self_ref` first: children may be listed before they
832        // are written (a rich cell's group is created after its content).
833        let mut refs: Vec<String> = Vec::with_capacity(tree.items.len());
834        let (mut nt, mut ng, mut ntb, mut np, mut nf, mut nk) = (0, 0, 0, 0, 0, 0);
835        for item in &tree.items {
836            if item.deleted {
837                refs.push(String::new());
838                continue;
839            }
840            let r = match &item.kind {
841                TreeKind::Text { .. } | TreeKind::Code { .. } => {
842                    nt += 1;
843                    format!("#/texts/{}", nt - 1)
844                }
845                TreeKind::Group { .. } => {
846                    ng += 1;
847                    format!("#/groups/{}", ng - 1)
848                }
849                TreeKind::Table { .. } => {
850                    ntb += 1;
851                    format!("#/tables/{}", ntb - 1)
852                }
853                TreeKind::Picture { .. } => {
854                    np += 1;
855                    format!("#/pictures/{}", np - 1)
856                }
857                TreeKind::FieldRegion { items } => {
858                    // A region's marker / key / value parts are text items
859                    // too, numbered where docling creates them.
860                    nt += items
861                        .iter()
862                        .map(|i| {
863                            [&i.marker, &i.key, &i.value]
864                                .iter()
865                                .filter(|p| p.is_some())
866                                .count()
867                        })
868                        .sum::<usize>();
869                    nf += 1;
870                    format!("#/field_regions/{}", nf - 1)
871                }
872                TreeKind::KeyValueGraph { .. } => {
873                    nk += 1;
874                    format!("#/key_value_items/{}", nk - 1)
875                }
876            };
877            refs.push(r);
878        }
879        let ref_of = |id: usize| ref_value(refs[id].as_str());
880        for (id, item) in tree.items.iter().enumerate() {
881            if item.deleted {
882                continue;
883            }
884            let parent = item.parent.map_or("#/body", |p| refs[p].as_str());
885            // A deleted item has no ref; one still listed as a child (a stale
886            // backend link) is left out rather than written as `"$ref": ""`,
887            // which docling-core rejects on load (#527).
888            let children: Vec<Value> = item
889                .children
890                .iter()
891                .filter(|&&c| !tree.items[c].deleted)
892                .map(|&c| ref_of(c))
893                .collect();
894            let layer = item.layer.map_or("body", |l| l.value());
895            // The item's own provenance, consumed by the writer below
896            // (`take_prov`); an item without one writes `prov: []`.
897            self.pending_exact = item.prov.as_ref().map(ExactProv::from);
898            let self_ref = match &item.kind {
899                TreeKind::Text {
900                    label,
901                    text,
902                    orig,
903                    formatting,
904                    hyperlink,
905                    level,
906                    list,
907                } => {
908                    // docling's field order: …, text, formatting, hyperlink,
909                    // then the subclass fields (`level`; `enumerated`, `marker`).
910                    let mut tail = serde_json::Map::new();
911                    if let Some(f) = formatting {
912                        tail.insert("formatting".into(), formatting_json(f));
913                    }
914                    if let Some(h) = hyperlink {
915                        tail.insert("hyperlink".into(), json!(h));
916                    }
917                    if let Some(l) = level {
918                        tail.insert("level".into(), json!(l));
919                    }
920                    if let Some(l) = list {
921                        tail.insert("enumerated".into(), json!(l.enumerated));
922                        tail.insert("marker".into(), json!(l.marker));
923                    }
924                    let r = format!("#/texts/{}", self.texts.len());
925                    let prov = self.take_prov(text.chars().count());
926                    let mut item_json = json!({
927                        "self_ref": r,
928                        "parent": { "$ref": parent },
929                        "children": children,
930                        "content_layer": layer,
931                        "label": label,
932                        "prov": prov,
933                    });
934                    // docling's `comments` back-refs sit between `prov` and
935                    // `orig`, and are written only when set.
936                    if !item.comments.is_empty() {
937                        item_json["comments"] =
938                            Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
939                    }
940                    // `DocItem.source`, likewise only when set: a WebVTT
941                    // cue's `TrackSource`, whose `None` fields are omitted.
942                    if let Some(track) = &item.source {
943                        item_json["source"] = json!([track_json(track)]);
944                    }
945                    merge(
946                        &mut item_json,
947                        json!({
948                            "orig": orig.as_deref().unwrap_or(text),
949                            "text": text,
950                        }),
951                    );
952                    merge(&mut item_json, Value::Object(tail));
953                    if self.note_keys {
954                        if !item.notes.is_empty() {
955                            item_json["_notes"] = Value::Array(
956                                item.notes
957                                    .iter()
958                                    .map(|n| json!([n.offset, n.text]))
959                                    .collect(),
960                            );
961                        }
962                        if item.note_body {
963                            item_json["_note_body"] = json!(true);
964                        }
965                    }
966                    self.texts.push(item_json);
967                    r
968                }
969                TreeKind::Code {
970                    text,
971                    orig,
972                    language,
973                    formatting,
974                    hyperlink,
975                } => {
976                    let r = format!("#/texts/{}", self.texts.len());
977                    let prov = self.take_prov(text.chars().count());
978                    let mut item_json = json!({
979                        "self_ref": r,
980                        "parent": { "$ref": parent },
981                        "children": children,
982                        "content_layer": layer,
983                        "label": "code",
984                        "prov": prov,
985                    });
986                    if !item.comments.is_empty() {
987                        item_json["comments"] =
988                            Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
989                    }
990                    merge(
991                        &mut item_json,
992                        json!({
993                            "orig": orig.as_deref().unwrap_or(text),
994                            "text": text,
995                        }),
996                    );
997                    if let Some(f) = formatting {
998                        item_json["formatting"] = formatting_json(f);
999                    }
1000                    if let Some(h) = hyperlink {
1001                        item_json["hyperlink"] = json!(h);
1002                    }
1003                    merge(
1004                        &mut item_json,
1005                        json!({
1006                            "captions": [],
1007                            "references": [],
1008                            "footnotes": [],
1009                            "code_language": code_language(language.as_deref()),
1010                        }),
1011                    );
1012                    self.texts.push(item_json);
1013                    r
1014                }
1015                TreeKind::Group { label, name } => {
1016                    self.pending_exact = None;
1017                    let r = format!("#/groups/{}", self.groups.len());
1018                    self.groups.push(json!({
1019                        "self_ref": r,
1020                        "parent": { "$ref": parent },
1021                        "children": children,
1022                        "content_layer": layer,
1023                        "name": name,
1024                        "label": label,
1025                    }));
1026                    r
1027                }
1028                TreeKind::Table {
1029                    table,
1030                    rich_cells,
1031                    captions,
1032                } => {
1033                    // docling's raw cell text: nothing to unescape or strip.
1034                    let r = self.add_table_with(table, parent, true);
1035                    let idx = ref_index(&r).expect("table ref");
1036                    let t = &mut self.tables[idx];
1037                    t["children"] = Value::Array(children);
1038                    t["content_layer"] = json!(layer);
1039                    t["captions"] = Value::Array(captions.iter().map(|&c| ref_of(c)).collect());
1040                    // A `RichTableCell` is the plain cell plus a `ref` to the
1041                    // group holding its content — on `table_cells` only; the
1042                    // derived `grid` shows plain cells.
1043                    for &(row, col, group) in rich_cells {
1044                        let cell_ref = ref_of(group);
1045                        let hit = |c: &Value| {
1046                            c["start_row_offset_idx"] == json!(row)
1047                                && c["start_col_offset_idx"] == json!(col)
1048                        };
1049                        if let Some(cells) = t["data"]["table_cells"].as_array_mut() {
1050                            for c in cells.iter_mut().filter(|c| hit(c)) {
1051                                c["ref"] = cell_ref.clone();
1052                            }
1053                        }
1054                    }
1055                    r
1056                }
1057                TreeKind::Picture {
1058                    captions,
1059                    image,
1060                    classification,
1061                    confidence,
1062                    chart,
1063                    dpi,
1064                } => {
1065                    // A chart's meta: the kind as the one classification
1066                    // prediction (pydantic's field order puts `confidence`
1067                    // first when present), then the reconstructed data grid
1068                    // (#405).
1069                    let mut meta = classification.as_ref().map(|c| {
1070                        let mut pred = serde_json::Map::new();
1071                        if let Some(conf) = confidence {
1072                            pred.insert("confidence".into(), json!(conf));
1073                        }
1074                        pred.insert("class_name".into(), json!(c));
1075                        json!({ "classification": { "predictions": [pred] } })
1076                    });
1077                    if let (Some(m), Some(t)) = (meta.as_mut(), chart) {
1078                        if !t.rows.is_empty() {
1079                            m["tabular_chart"] = json!({ "chart_data": table_data(t) });
1080                        }
1081                    }
1082                    let prov = self.take_prov(0);
1083                    let r = self.push_picture(
1084                        prov,
1085                        captions.iter().map(|&c| ref_of(c)).collect(),
1086                        children,
1087                        image.as_ref(),
1088                        meta,
1089                        parent,
1090                    );
1091                    if let Some(idx) = ref_index(&r) {
1092                        self.pictures[idx]["content_layer"] = json!(layer);
1093                        // The image's dpi is the file's when the backend read
1094                        // it (python-pptx does); the default 72 otherwise.
1095                        if let (Some(dpi), Some(img)) = (dpi, self.pictures[idx].get_mut("image")) {
1096                            img["dpi"] = json!(dpi);
1097                        }
1098                    }
1099                    r
1100                }
1101                TreeKind::FieldRegion { items } => {
1102                    self.pending_exact = None;
1103                    let r = self.add_field_region(items, parent);
1104                    if let Some(region) = self.field_regions.last_mut() {
1105                        region["content_layer"] = json!(layer);
1106                    }
1107                    r
1108                }
1109                TreeKind::KeyValueGraph { cells, links } => {
1110                    self.pending_exact = None;
1111                    let r = self.add_key_value_graph(cells, links, parent);
1112                    if let Some(item) = self.key_value_items.last_mut() {
1113                        item["content_layer"] = json!(layer);
1114                    }
1115                    r
1116                }
1117            };
1118            debug_assert_eq!(self_ref, refs[id], "tree item {id} numbered out of order");
1119        }
1120        tree.body
1121            .iter()
1122            .filter(|&&c| !tree.items[c].deleted)
1123            .map(|&c| ref_of(c))
1124            .collect()
1125    }
1126
1127    fn add_node(&mut self, node: &Node, parent: &str) -> Option<String> {
1128        match node {
1129            Node::Heading { level: 1, text } => {
1130                Some(self.add_text("title", text, parent, json!({})))
1131            }
1132            Node::Heading { level, text } => Some(self.add_text(
1133                "section_header",
1134                text,
1135                parent,
1136                json!({ "level": level.saturating_sub(1) }),
1137            )),
1138            Node::Caption { text, href } => {
1139                let extra = match href {
1140                    Some(url) => json!({ "hyperlink": url }),
1141                    None => json!({}),
1142                };
1143                Some(self.add_text("caption", text, parent, extra))
1144            }
1145            // A PDF footnote: docling's own label, and its link as the item's
1146            // `hyperlink` rather than Markdown baked into the text.
1147            Node::LabeledText { label, text, href } => {
1148                let extra = match href {
1149                    Some(url) => json!({ "hyperlink": url }),
1150                    None => json!({}),
1151                };
1152                Some(self.add_text(label, text, parent, extra))
1153            }
1154            Node::Paragraph { text } => {
1155                // A whole-paragraph display equation is a formula item (docling
1156                // wraps it in `$$…$$` and, unlike a text item, never escapes it).
1157                let t = text.trim();
1158                match t.strip_prefix("$$").and_then(|s| s.strip_suffix("$$")) {
1159                    Some(inner) if !inner.is_empty() => Some(self.add_formula(inner, parent)),
1160                    _ => Some(self.add_text("text", text, parent, json!({}))),
1161                }
1162            }
1163            Node::CheckboxItem { checked, text } => {
1164                // docling's checkbox item: the `checkbox_selected` /
1165                // `checkbox_unselected` label carries the state and the text
1166                // is the bare option label (right_to_left_03's `خير`,
1167                // docx_checkboxes' `Orange juice`). The `- [x]` task-list
1168                // marker is Markdown's rendering of it, not item text (#609).
1169                let label = if *checked {
1170                    "checkbox_selected"
1171                } else {
1172                    "checkbox_unselected"
1173                };
1174                Some(self.add_text(label, text, parent, json!({})))
1175            }
1176            Node::Code {
1177                language,
1178                text,
1179                orig,
1180                ..
1181            } => Some(self.add_code(text, language.as_deref(), orig.as_deref(), parent)),
1182            // A CodeFormula-decoded display formula: `text` carries the LaTeX,
1183            // `orig` the raw glyph extraction (docling's enriched shape).
1184            Node::Formula {
1185                latex,
1186                orig,
1187                location,
1188            } => {
1189                self.adopt_loc(*location);
1190                Some(self.add_formula_item(latex, orig, parent))
1191            }
1192            // docling's notes-layer `comment_section` group holding the
1193            // comment's text item. What the annotated items point at differs
1194            // upstream: the docx backend links the group (so a comment's
1195            // replies group together), everything going through
1196            // docling-core's `add_comment` links the note text itself.
1197            Node::CommentSection {
1198                name,
1199                text,
1200                refs_note_text,
1201                grouped,
1202            } => {
1203                if !*grouped {
1204                    // docling-core's bare `add_comment`: the note text sits
1205                    // directly under the parent and is what the back-refs
1206                    // point at.
1207                    let child =
1208                        self.add_text("text", text, parent, json!({ "content_layer": "notes" }));
1209                    self.comment_groups.push(child.clone());
1210                    return Some(child);
1211                }
1212                if let Some((_, self_ref)) = self
1213                    .last_comment_section
1214                    .as_ref()
1215                    .filter(|(n, _)| n == name)
1216                    .cloned()
1217                {
1218                    // Another message of the same thread: a further note in
1219                    // the group already open for this cell.
1220                    let child =
1221                        self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
1222                    if let Some(children) =
1223                        self.groups[group_index(&self_ref)]["children"].as_array_mut()
1224                    {
1225                        children.push(ref_value(child.as_str()));
1226                    }
1227                    self.comment_groups.push(if *refs_note_text {
1228                        child
1229                    } else {
1230                        self_ref.clone()
1231                    });
1232                    return Some(self_ref);
1233                }
1234                let self_ref = format!("#/groups/{}", self.groups.len());
1235                self.groups.push(Value::Null);
1236                let child =
1237                    self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
1238                self.groups[group_index(&self_ref)] = json!({
1239                    "self_ref": self_ref,
1240                    "parent": { "$ref": parent },
1241                    "children": [{ "$ref": child }],
1242                    "content_layer": "notes",
1243                    "name": name,
1244                    "label": "comment_section",
1245                });
1246                self.last_comment_section = Some((name.clone(), self_ref.clone()));
1247                self.comment_groups.push(if *refs_note_text {
1248                    child
1249                } else {
1250                    self_ref.clone()
1251                });
1252                Some(self_ref)
1253            }
1254            // The annotation itself is a cross-reference: emit the item, then
1255            // remember it so the group refs can be filled in at the end.
1256            Node::Commented { comments, inner } => {
1257                let item = self.add_node(inner, parent)?;
1258                if !comments.is_empty() {
1259                    self.pending_comments.push((item.clone(), comments.clone()));
1260                }
1261                Some(item)
1262            }
1263            Node::Table(t) => Some(self.add_table(t, parent)),
1264            Node::Picture {
1265                caption,
1266                caption_href,
1267                image,
1268                classification,
1269                caption_parent,
1270                caption_location,
1271            } => Some(self.add_picture(
1272                caption.as_deref(),
1273                caption_href.as_deref(),
1274                image.as_ref(),
1275                classification.as_deref().map(classification_meta),
1276                parent,
1277                (*caption_parent, *caption_location),
1278            )),
1279            // A chart is a picture item in the JSON with docling's chart
1280            // meta — `classification` (the chart kind, as the one prediction)
1281            // and `tabular_chart.chart_data`, the series reconstructed as a
1282            // `TableData` (#405) — and no image payload.
1283            Node::Chart {
1284                kind,
1285                table,
1286                caption,
1287                location,
1288            } => {
1289                self.adopt_loc(*location);
1290                let mut meta = json!({
1291                    "classification": { "predictions": [{ "class_name": kind }] },
1292                });
1293                if !table.rows.is_empty() {
1294                    meta["tabular_chart"] = json!({ "chart_data": table_data(table) });
1295                }
1296                // docling's office backends add the chart's title as a caption
1297                // item of the *container* (the sheet group, the slide), listed
1298                // before the picture that references it, with the chart's own
1299                // box and a charspan over the caption text — not as a child
1300                // of the picture, which is where a PDF caption lives.
1301                let mut captions = Vec::new();
1302                if let Some(cap) = caption.as_deref().filter(|c| !c.is_empty()) {
1303                    let prov = self.prov_json(unescape_text(cap).chars().count(), true);
1304                    let cap_ref = self.add_text_with("caption", cap, parent, json!({}), prov);
1305                    self.pending_siblings.push(ref_value(cap_ref.as_str()));
1306                    captions.push(ref_value(cap_ref));
1307                }
1308                let prov = self.take_prov(0);
1309                Some(self.push_picture(prov, captions, Vec::new(), None, Some(meta), parent))
1310            }
1311            // A DocLang-only node is omitted from the JSON body.
1312            Node::DoclangOnly(_) => None,
1313            Node::Group {
1314                label,
1315                name,
1316                layer,
1317                children,
1318            } => Some(self.add_group(label, name.as_deref(), *layer, children, parent)),
1319            Node::FieldRegion { items } => Some(self.add_field_region(items, parent)),
1320            Node::KeyValueGraph { cells, links } => {
1321                Some(self.add_key_value_graph(cells, links, parent))
1322            }
1323            // A rich inline group is a text item over its Markdown text; the
1324            // structured runs are DocLang-only, so the JSON matches a paragraph.
1325            Node::InlineGroup { md_text, .. } => {
1326                Some(self.add_text("text", md_text, parent, json!({})))
1327            }
1328            // A plain-text backend dump is a single text item over the file body.
1329            Node::TextDump(text) => Some(self.add_text("text", text, parent, json!({}))),
1330            // Speaker notes are content a deck carries, and docling puts them
1331            // in the JSON on their own layer, so a consumer reading only JSON
1332            // can pick them (#402). Page furniture stays out of the flat
1333            // path; a backend that builds docling's item tree (HTML) puts its
1334            // furniture-layer items in the JSON through `write_tree`.
1335            Node::Furniture {
1336                layer: ContentLayer::Notes,
1337                inner,
1338            } => {
1339                let item = self.add_node(inner, parent)?;
1340                self.set_layer(&item, "notes");
1341                Some(item)
1342            }
1343            Node::Furniture { .. } => None,
1344            // PDF page headers and footers: docling writes them as body-parented
1345            // `page_header`/`page_footer` text items on the furniture layer, so a
1346            // JSON consumer can read running headers (printed page ids, dates).
1347            Node::PageFurniture {
1348                footer,
1349                location,
1350                text,
1351            } => {
1352                if self.cur_page > 0 {
1353                    self.pending_loc = Some(*location);
1354                }
1355                let label = if *footer {
1356                    "page_footer"
1357                } else {
1358                    "page_header"
1359                };
1360                let item = self.add_text(label, text, parent, json!({}));
1361                self.pending_loc = None;
1362                self.set_layer(&item, "furniture");
1363                Some(item)
1364            }
1365            // A labelled furniture paragraph (a `.doc` header / footer or
1366            // note body, #535): the item docling's office backends write.
1367            Node::FurnitureText { label, text } => {
1368                let item = self.add_text(label, text, parent, json!({}));
1369                self.set_layer(&item, "furniture");
1370                Some(item)
1371            }
1372            // A PDF picture's contained text (docling's `_add_child_elements`):
1373            // items parented to the picture just written, listed after its
1374            // caption in that picture's `children`.
1375            Node::PictureChildren(children) => {
1376                let pic = self.pictures.len().checked_sub(1)?;
1377                let pic_ref = format!("#/pictures/{pic}");
1378                let refs = self.walk_into(children, &pic_ref);
1379                if let Some(list) = self.pictures[pic]["children"].as_array_mut() {
1380                    list.extend(refs);
1381                }
1382                None
1383            }
1384            // A location wrapper turns into the wrapped item's `prov` entry —
1385            // but only on pages the PDF paths described with a PageInfo marker
1386            // (other geometry-bearing backends, e.g. PPTX shapes, keep their
1387            // pre-#171 provenance-less JSON until they emit markers too).
1388            Node::Located { location, inner } => {
1389                if self.cur_page > 0 {
1390                    self.pending_loc = Some(*location);
1391                }
1392                let r = self.add_node(inner, parent);
1393                self.pending_loc = None;
1394                r
1395            }
1396            Node::Track { track, cue, inner } => {
1397                self.pending_track = Some((track.clone(), cue.clone()));
1398                let r = self.add_node(inner, parent);
1399                self.pending_track = None;
1400                r
1401            }
1402            Node::Prov {
1403                page_no,
1404                bbox,
1405                charspan,
1406                inner,
1407                ..
1408            } => {
1409                self.pending_exact = Some(ExactProv {
1410                    page_no: *page_no,
1411                    bbox: bbox.map(f64::from),
1412                    bottom_left: false,
1413                    charspan: *charspan,
1414                });
1415                let r = self.add_node(inner, parent);
1416                self.pending_exact = None;
1417                r
1418            }
1419            // Page breaks are DocLang-only; docling omits them from the JSON body.
1420            Node::PageBreak => None,
1421            // The page marker: record the page's number and size for the
1422            // `pages` map, and denormalize every following location against it.
1423            Node::PageInfo {
1424                page_no,
1425                width,
1426                height,
1427            } => {
1428                self.cur_page = *page_no;
1429                self.cur_w = *width as f64;
1430                self.cur_h = *height as f64;
1431                if *page_no > 0 {
1432                    self.pages.push((*page_no, self.cur_w, self.cur_h));
1433                }
1434                None
1435            }
1436            // Handled by `add_list` in `walk`.
1437            Node::ListItem { .. } => None,
1438        }
1439    }
1440
1441    /// A form key-value region: `field_regions/N` holds the region, each field is
1442    /// a `field_items/M` whose children are its `marker` / `field_key` /
1443    /// `field_value` texts (absent parts are simply omitted).
1444    fn add_field_region(&mut self, items: &[crate::FieldItem], parent: &str) -> String {
1445        let self_ref = format!("#/field_regions/{}", self.field_regions.len());
1446        self.field_regions.push(Value::Null);
1447        let region_index = self.field_regions.len() - 1;
1448        let mut item_refs = Vec::new();
1449        for item in items {
1450            item_refs.push(ref_value(self.add_field_item(item, &self_ref)));
1451        }
1452        self.field_regions[region_index] = json!({
1453            "self_ref": self_ref,
1454            "parent": { "$ref": parent },
1455            "children": item_refs,
1456            "content_layer": "body",
1457            "label": "field_region",
1458            "prov": [],
1459        });
1460        self_ref
1461    }
1462
1463    /// A `KeyValueItem`: docling's `GraphData` written cell for cell and link
1464    /// for link (`key_value_items/N`), the item itself childless and without
1465    /// provenance, the way the XBRL backend creates it.
1466    fn add_key_value_graph(
1467        &mut self,
1468        cells: &[crate::GraphCell],
1469        links: &[crate::GraphLink],
1470        parent: &str,
1471    ) -> String {
1472        let self_ref = format!("#/key_value_items/{}", self.key_value_items.len());
1473        let cells: Vec<Value> = cells
1474            .iter()
1475            .map(|c| {
1476                json!({
1477                    "label": c.label,
1478                    "cell_id": c.cell_id,
1479                    "text": c.text,
1480                    "orig": c.orig,
1481                })
1482            })
1483            .collect();
1484        let links: Vec<Value> = links
1485            .iter()
1486            .map(|l| {
1487                json!({
1488                    "label": l.label,
1489                    "source_cell_id": l.source_cell_id,
1490                    "target_cell_id": l.target_cell_id,
1491                })
1492            })
1493            .collect();
1494        self.key_value_items.push(json!({
1495            "self_ref": self_ref,
1496            "parent": { "$ref": parent },
1497            "children": [],
1498            "content_layer": "body",
1499            "label": "key_value_region",
1500            "prov": [],
1501            "captions": [],
1502            "references": [],
1503            "footnotes": [],
1504            "graph": { "cells": cells, "links": links },
1505        }));
1506        self_ref
1507    }
1508
1509    fn add_field_item(&mut self, item: &crate::FieldItem, parent: &str) -> String {
1510        let self_ref = format!("#/field_items/{}", self.field_items.len());
1511        self.field_items.push(Value::Null);
1512        let item_index = self.field_items.len() - 1;
1513        let mut child_refs = Vec::new();
1514        for (label, text) in [
1515            ("marker", &item.marker),
1516            ("field_key", &item.key),
1517            ("field_value", &item.value),
1518        ] {
1519            if let Some(text) = text {
1520                // A value's `kind` (docling's `read_only` / `fillable`)
1521                // follows its text.
1522                let extra = match (label, &item.value_kind) {
1523                    ("field_value", Some(kind)) => json!({ "kind": kind }),
1524                    _ => json!({}),
1525                };
1526                child_refs.push(ref_value(self.add_text(label, text, &self_ref, extra)));
1527            }
1528        }
1529        self.field_items[item_index] = json!({
1530            "self_ref": self_ref,
1531            "parent": { "$ref": parent },
1532            "children": child_refs,
1533            "content_layer": "body",
1534            "label": "field_item",
1535            "prov": [],
1536        });
1537        self_ref
1538    }
1539
1540    /// Move an already-emitted item onto a content layer. Notes are single
1541    /// text items today; a deeper notes subtree would need its children moved
1542    /// too, and no backend builds one.
1543    fn set_layer(&mut self, self_ref: &str, layer: &str) {
1544        let bucket = match self_ref.split('/').nth(1) {
1545            Some("texts") => &mut self.texts,
1546            Some("tables") => &mut self.tables,
1547            Some("pictures") => &mut self.pictures,
1548            Some("groups") => &mut self.groups,
1549            _ => return,
1550        };
1551        if let Some(item) = self_ref
1552            .rsplit('/')
1553            .next()
1554            .and_then(|i| i.parse::<usize>().ok())
1555            .and_then(|i| bucket.get_mut(i))
1556        {
1557            item["content_layer"] = json!(layer);
1558        }
1559    }
1560
1561    fn add_text(&mut self, label: &str, text: &str, parent: &str, extra: Value) -> String {
1562        let prov = self.take_prov(unescape_text(text).chars().count());
1563        self.add_text_with(label, text, parent, extra, prov)
1564    }
1565
1566    /// [`Self::add_text`] with an explicit `prov` (a chart caption shares the
1567    /// chart's box without consuming it).
1568    fn add_text_with(
1569        &mut self,
1570        label: &str,
1571        text: &str,
1572        parent: &str,
1573        extra: Value,
1574        prov: Value,
1575    ) -> String {
1576        let self_ref = format!("#/texts/{}", self.texts.len());
1577        // A [`Node::Track`] item is docling's ASR text item (#614): the
1578        // segment's words as `text`, its timing as `source` — the `[time: …]`
1579        // prefix the wrapped paragraph shows is Markdown's alone.
1580        let (raw, track) = match self.pending_track.take() {
1581            Some((track, cue)) => (cue, Some(track)),
1582            None => (unescape_text(text), None),
1583        };
1584        let mut m = serde_json::Map::with_capacity(8 + usize::from(track.is_some()));
1585        m.insert("self_ref".into(), Value::String(self_ref.clone()));
1586        m.insert("parent".into(), ref_value(parent));
1587        m.insert("children".into(), Value::Array(Vec::new()));
1588        m.insert("content_layer".into(), "body".into());
1589        m.insert("label".into(), label.into());
1590        m.insert("prov".into(), prov);
1591        // `DocItem.source` follows `prov` (docling's field order), only when
1592        // set: an ASR segment's track (#614).
1593        if let Some(track) = track {
1594            m.insert("source".into(), Value::Array(vec![track_json(&track)]));
1595        }
1596        m.insert("orig".into(), Value::String(raw.clone()));
1597        m.insert("text".into(), Value::String(raw));
1598        let mut item = Value::Object(m);
1599        if extra.as_object().is_some_and(|e| !e.is_empty()) {
1600            merge(&mut item, extra);
1601        }
1602        self.texts.push(item);
1603        self_ref
1604    }
1605
1606    /// A display-math formula item. `latex` is the raw content (no `$$`); docling
1607    /// re-wraps it and never escapes it.
1608    fn add_formula(&mut self, latex: &str, parent: &str) -> String {
1609        let self_ref = format!("#/texts/{}", self.texts.len());
1610        let prov = self.take_prov(latex.chars().count());
1611        self.texts.push(json!({
1612            "self_ref": self_ref,
1613            "parent": { "$ref": parent },
1614            "children": [],
1615            "content_layer": "body",
1616            "label": "formula",
1617            "prov": prov,
1618            "orig": latex,
1619            "text": latex,
1620        }));
1621        self_ref
1622    }
1623
1624    /// A CodeFormula-enriched display formula: `text` is the model's LaTeX
1625    /// while `orig` keeps the raw glyph extraction (docling's enriched shape;
1626    /// the plain [`Self::add_formula`] above sets both to the same string).
1627    fn add_formula_item(&mut self, latex: &str, orig: &str, parent: &str) -> String {
1628        let self_ref = format!("#/texts/{}", self.texts.len());
1629        let prov = self.take_prov(latex.chars().count());
1630        self.texts.push(json!({
1631            "self_ref": self_ref,
1632            "parent": { "$ref": parent },
1633            "children": [],
1634            "content_layer": "body",
1635            "label": "formula",
1636            "prov": prov,
1637            "orig": orig,
1638            "text": latex,
1639        }));
1640        self_ref
1641    }
1642
1643    fn add_code(
1644        &mut self,
1645        text: &str,
1646        language: Option<&str>,
1647        orig: Option<&str>,
1648        parent: &str,
1649    ) -> String {
1650        let self_ref = format!("#/texts/{}", self.texts.len());
1651        let raw = unescape_text(text);
1652        let prov = self.take_prov(raw.chars().count());
1653        self.texts.push(json!({
1654            "self_ref": self_ref,
1655            "parent": { "$ref": parent },
1656            "children": [],
1657            "content_layer": "body",
1658            "label": "code",
1659            "prov": prov,
1660            // With code enrichment, `text` is the model's rewrite while `orig`
1661            // keeps the raw extraction; otherwise both are the same string.
1662            "orig": orig.map(unescape_text).unwrap_or_else(|| raw.clone()),
1663            "text": raw,
1664            "captions": [],
1665            "references": [],
1666            "footnotes": [],
1667            "code_language": code_language(language),
1668        }));
1669        self_ref
1670    }
1671
1672    /// Build a list group from a run of (possibly multi-level) list items. A
1673    /// deeper level starts a nested list under the preceding item.
1674    fn add_list(&mut self, items: &[Node], parent: &str) -> String {
1675        let self_ref = format!("#/groups/{}", self.groups.len());
1676        // reserve the slot so nested groups get later indices
1677        self.groups.push(Value::Null);
1678        let base = level_of(&items[0]);
1679        let mut children = Vec::new();
1680        let mut i = 0;
1681        while i < items.len() {
1682            // Empty paragraphs absorbed into the run (blank lines between items)
1683            // are not list items — skip them.
1684            if !matches!(items[i], Node::ListItem { .. }) {
1685                i += 1;
1686                continue;
1687            }
1688            let lvl = level_of(&items[i]);
1689            if lvl > base {
1690                // shouldn't happen at the head; skip defensively
1691                i += 1;
1692                continue;
1693            }
1694            let item_ref = self.add_list_item(&items[i], &self_ref);
1695            // collect any deeper items that nest under this one
1696            let mut j = i + 1;
1697            while j < items.len() && level_of(&items[j]) > base {
1698                j += 1;
1699            }
1700            if j > i + 1 {
1701                let mut nested = Vec::new();
1702                self.add_sibling_lists(&items[i + 1..j], &item_ref, &mut nested);
1703                // the nested list group(s) are children of this item
1704                if let Some(idx) = ref_index(&item_ref) {
1705                    self.texts[idx]["children"]
1706                        .as_array_mut()
1707                        .unwrap()
1708                        .extend(nested);
1709                }
1710            }
1711            children.push(ref_value(item_ref));
1712            i = j;
1713        }
1714        self.groups[group_index(&self_ref)] = json!({
1715            "self_ref": self_ref,
1716            "parent": { "$ref": parent },
1717            "children": children,
1718            "content_layer": "body",
1719            "name": "list",
1720            "label": "list",
1721        });
1722        self_ref
1723    }
1724
1725    fn add_list_item(&mut self, node: &Node, parent: &str) -> String {
1726        let Node::ListItem {
1727            ordered,
1728            number,
1729            text,
1730            location,
1731            ..
1732        } = node
1733        else {
1734            unreachable!()
1735        };
1736        self.adopt_loc(*location);
1737        let self_ref = format!("#/texts/{}", self.texts.len());
1738        let raw = unescape_text(text);
1739        let prov = self.take_prov(raw.chars().count());
1740        let marker = if *ordered {
1741            format!("{number}.")
1742        } else {
1743            "-".to_string()
1744        };
1745        self.texts.push(json!({
1746            "self_ref": self_ref,
1747            "parent": { "$ref": parent },
1748            "children": [],
1749            "content_layer": "body",
1750            "label": "list_item",
1751            "prov": prov,
1752            "orig": raw,
1753            "text": raw,
1754            "enumerated": ordered,
1755            "marker": marker,
1756        }));
1757        self_ref
1758    }
1759
1760    fn add_table(&mut self, t: &Table, parent: &str) -> String {
1761        self.add_table_with(t, parent, false)
1762    }
1763
1764    /// [`Self::add_table`]; `raw` cell text is written verbatim (see
1765    /// [`table_data_with`]).
1766    fn add_table_with(&mut self, t: &Table, parent: &str, raw: bool) -> String {
1767        let self_ref = format!("#/tables/{}", self.tables.len());
1768        self.adopt_loc(t.location);
1769        let prov = self.take_prov(0);
1770        // The caption is a separate text item the table references (docling's
1771        // `TableItem.captions`), added before the grid so its box isn't
1772        // inherited by a later item.
1773        let (captions, children) = match t.caption.as_deref().filter(|c| !c.is_empty()) {
1774            Some(cap) => self.add_caption(
1775                cap,
1776                json!({}),
1777                &self_ref,
1778                parent,
1779                (t.caption_parent, t.caption_location),
1780            ),
1781            None => (Vec::new(), Vec::new()),
1782        };
1783        let data = table_data_with(t, raw);
1784        self.tables.push(json!({
1785            "self_ref": self_ref,
1786            "parent": { "$ref": parent },
1787            "children": children,
1788            "content_layer": "body",
1789            "label": "table",
1790            "prov": prov,
1791            "captions": captions,
1792            "references": [],
1793            "footnotes": [],
1794            "data": data,
1795            "annotations": [],
1796        }));
1797        self_ref
1798    }
1799
1800    /// Add a picture's or table's caption text item where `choice` says it
1801    /// hangs (#390), returning the `captions` entry for the item and the
1802    /// item's own `children` (the caption, when it is the item's child).
1803    /// The caption never consumes the item's pending provenance — the item
1804    /// takes its box first. Its own box, when the backend kept one (the PDF
1805    /// pipeline's caption region, #609), becomes the caption's `prov` on the
1806    /// item's page, as docling's reading-order model gives every caption the
1807    /// cluster it came from; without one the caption has no `prov`.
1808    fn add_caption(
1809        &mut self,
1810        text: &str,
1811        extra: Value,
1812        self_ref: &str,
1813        parent: &str,
1814        (choice, location): (CaptionParent, Option<[u16; 4]>),
1815    ) -> (Vec<Value>, Vec<Value>) {
1816        // docling's PDF pipeline parents the caption to the item; every
1817        // declarative backend leaves `add_text`'s default — the body — even
1818        // for an item inside a group; the office backends and HTML's
1819        // `<figure>` hang it off the item's container.
1820        let cap_parent = match choice {
1821            CaptionParent::Item => self_ref,
1822            CaptionParent::Container | CaptionParent::ContainerAfter => parent,
1823            CaptionParent::Body => "#/body",
1824        };
1825        if self.cur_page > 0 && location.is_some() {
1826            self.pending_loc = location;
1827        }
1828        let cap_ref = ref_value(self.add_text("caption", text, cap_parent, extra));
1829        match choice {
1830            CaptionParent::Item => return (vec![cap_ref.clone()], vec![cap_ref]),
1831            // Created ahead of the item, so it precedes the item in the
1832            // container's children — and, on the body, in the body's.
1833            CaptionParent::Container => self.pending_siblings.push(cap_ref.clone()),
1834            CaptionParent::Body if parent == "#/body" => {
1835                self.pending_siblings.push(cap_ref.clone())
1836            }
1837            CaptionParent::ContainerAfter => self.pending_after.push(cap_ref.clone()),
1838            // The item sits deeper: the body's children get the caption after
1839            // the top-level item under walk, where docling appended it.
1840            CaptionParent::Body => self.pending_body.push(cap_ref.clone()),
1841        }
1842        (vec![cap_ref], Vec::new())
1843    }
1844
1845    /// `meta` is the picture's docling `PictureMeta` (a classifier's
1846    /// predictions, a chart's kind and data), `None` for a plain picture.
1847    fn add_picture(
1848        &mut self,
1849        caption: Option<&str>,
1850        caption_href: Option<&str>,
1851        image: Option<&crate::PictureImage>,
1852        meta: Option<Value>,
1853        parent: &str,
1854        caption_at: (CaptionParent, Option<[u16; 4]>),
1855    ) -> String {
1856        let self_ref = format!("#/pictures/{}", self.pictures.len());
1857        // Take the picture's own provenance before the caption text is added —
1858        // the caption is a separate item and must not inherit the crop's box.
1859        let prov = self.take_prov(0);
1860        let (captions, children) = match caption.filter(|c| !c.is_empty()) {
1861            Some(cap) => {
1862                // Emit the caption as a text item that the picture references. A
1863                // wrapping `<a href>`'s link rides as docling's `hyperlink` field
1864                // on the caption item (#328).
1865                let extra = match caption_href {
1866                    Some(href) => json!({ "hyperlink": href }),
1867                    None => json!({}),
1868                };
1869                self.add_caption(cap, extra, &self_ref, parent, caption_at)
1870            }
1871            None => (Vec::new(), Vec::new()),
1872        };
1873        self.push_picture(prov, captions, children, image, meta, parent)
1874    }
1875
1876    /// Append the picture item itself — `prov`, `captions` and `children`
1877    /// (a PDF caption is the picture's child) already settled.
1878    fn push_picture(
1879        &mut self,
1880        prov: Value,
1881        captions: Vec<Value>,
1882        children: Vec<Value>,
1883        image: Option<&crate::PictureImage>,
1884        meta: Option<Value>,
1885        parent: &str,
1886    ) -> String {
1887        let self_ref = format!("#/pictures/{}", self.pictures.len());
1888        // The legacy `classification` annotation rides along with a
1889        // classifier's `meta` (see `classification_meta`); a chart's meta has
1890        // none, like docling's.
1891        let annotations = meta
1892            .as_ref()
1893            .and_then(|m| m.get("annotations").cloned())
1894            .unwrap_or_else(|| json!([]));
1895        let meta = meta.map(|mut m| {
1896            if let Some(obj) = m.as_object_mut() {
1897                obj.remove("annotations");
1898            }
1899            m
1900        });
1901        // `meta` sits between `content_layer` and `label` in docling's field
1902        // order (and `preserve_order` keeps ours byte-compatible), so the item
1903        // is built in one shot per shape rather than patched afterwards.
1904        let mut item = match meta {
1905            Some(meta) => json!({
1906                "self_ref": self_ref,
1907                "parent": { "$ref": parent },
1908                "children": children,
1909                "content_layer": "body",
1910                "meta": meta,
1911                "label": "picture",
1912                "prov": prov,
1913                "captions": captions,
1914                "references": [],
1915                "footnotes": [],
1916                "annotations": annotations,
1917            }),
1918            None => json!({
1919                "self_ref": self_ref,
1920                "parent": { "$ref": parent },
1921                "children": children,
1922                "content_layer": "body",
1923                "label": "picture",
1924                "prov": prov,
1925                "captions": captions,
1926                "references": [],
1927                "footnotes": [],
1928                "annotations": annotations,
1929            }),
1930        };
1931        // docling stores the extracted image as an `ImageRef` (data URI + size,
1932        // the size as floats) between `footnotes` and `annotations` — pydantic
1933        // field order, which `preserve_order` lets us reproduce by rebuilding
1934        // the tail.
1935        if let Some(img) = image {
1936            let image = image_ref_json(img);
1937            if let Some(obj) = item.as_object_mut() {
1938                let annotations = obj.remove("annotations").unwrap_or_else(|| json!([]));
1939                obj.insert("image".into(), image);
1940                obj.insert("annotations".into(), annotations);
1941            }
1942        }
1943        self.pictures.push(item);
1944        self_ref
1945    }
1946
1947    fn add_group(
1948        &mut self,
1949        label: &str,
1950        name: Option<&str>,
1951        layer: Option<ContentLayer>,
1952        nodes: &[Node],
1953        parent: &str,
1954    ) -> String {
1955        let self_ref = format!("#/groups/{}", self.groups.len());
1956        self.groups.push(Value::Null);
1957        // Everything the walk creates belongs to this group, so a non-body
1958        // layer (a hidden sheet) is stamped on the whole subtree afterwards —
1959        // docling puts the layer on the group *and* on every item under it.
1960        let mark = (
1961            self.texts.len(),
1962            self.tables.len(),
1963            self.pictures.len(),
1964            self.groups.len(),
1965        );
1966        let children = self.walk_into(nodes, &self_ref);
1967        let name = name.unwrap_or(if label == "inline" { "group" } else { label });
1968        let content_layer = layer.map_or("body", |l| l.value());
1969        self.groups[group_index(&self_ref)] = json!({
1970            "self_ref": self_ref,
1971            "parent": { "$ref": parent },
1972            "children": children,
1973            "content_layer": content_layer,
1974            "name": name,
1975            "label": label,
1976        });
1977        if layer.is_some() {
1978            let (t, tb, p, g) = mark;
1979            for item in self.texts[t..]
1980                .iter_mut()
1981                .chain(self.tables[tb..].iter_mut())
1982                .chain(self.pictures[p..].iter_mut())
1983                .chain(self.groups[g..].iter_mut())
1984            {
1985                if let Some(obj) = item.as_object_mut() {
1986                    obj.insert("content_layer".into(), json!(content_layer));
1987                }
1988            }
1989        }
1990        self_ref
1991    }
1992
1993    /// Walk a slice of sibling nodes, returning each child's `$ref`; runs of
1994    /// list items are folded into list groups (one per sibling list).
1995    fn walk_into(&mut self, nodes: &[Node], parent: &str) -> Vec<Value> {
1996        // Siblings that all carry a creation rank (an XLSX sheet's items) are
1997        // *added* in that order — so `#/tables/N` and friends are numbered as
1998        // docling numbers them — while their refs keep the node order, which
1999        // is docling's position-sorted `children`.
2000        let seqs: Option<Vec<usize>> = nodes
2001            .iter()
2002            .map(|n| match n {
2003                Node::Prov { seq: Some(s), .. } => Some(*s),
2004                _ => None,
2005            })
2006            .collect();
2007        if let Some(seqs) = seqs.filter(|s| !s.is_empty()) {
2008            let mut order: Vec<usize> = (0..nodes.len()).collect();
2009            order.sort_by_key(|&i| seqs[i]);
2010            let mut slots: Vec<Vec<Value>> = vec![Vec::new(); nodes.len()];
2011            for i in order {
2012                if let Some(r) = self.add_node(&nodes[i], parent) {
2013                    slots[i].append(&mut self.pending_siblings);
2014                    slots[i].push(ref_value(r));
2015                    slots[i].append(&mut self.pending_after);
2016                }
2017                if parent == "#/body" {
2018                    slots[i].append(&mut self.pending_body);
2019                }
2020            }
2021            return slots.into_iter().flatten().collect();
2022        }
2023        let mut children = Vec::new();
2024        let mut i = 0;
2025        while i < nodes.len() {
2026            if matches!(nodes[i], Node::ListItem { .. }) {
2027                let start = i;
2028                i += 1;
2029                loop {
2030                    match nodes.get(i) {
2031                        Some(Node::ListItem { .. }) => i += 1,
2032                        // Absorb an empty paragraph sitting between two list
2033                        // items (docling keeps the ListGroup contiguous).
2034                        Some(Node::Paragraph { text })
2035                            if text.is_empty()
2036                                && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
2037                        {
2038                            i += 1
2039                        }
2040                        _ => break,
2041                    }
2042                }
2043                self.add_sibling_lists(&nodes[start..i], parent, &mut children);
2044            } else {
2045                if let Some(r) = self.add_node(&nodes[i], parent) {
2046                    children.append(&mut self.pending_siblings);
2047                    children.push(ref_value(r));
2048                    children.append(&mut self.pending_after);
2049                }
2050                i += 1;
2051            }
2052            // Body-parented captions of items deeper in the tree follow the
2053            // top-level item they were created under (#390).
2054            if parent == "#/body" {
2055                children.append(&mut self.pending_body);
2056            }
2057        }
2058        children
2059    }
2060
2061    /// A run of list items may hold several *sibling* lists; emit one list group
2062    /// per sibling. The boundary is the backend's `first_in_list` flag on a
2063    /// base-level item — the same rule as the Markdown serializer's blank line
2064    /// (#385; the kind-flip and number-gap guesses are gone).
2065    fn add_sibling_lists(&mut self, run: &[Node], parent: &str, out: &mut Vec<Value>) {
2066        let base = level_of(&run[0]);
2067        let mut seg = 0;
2068        for k in 0..run.len() {
2069            let Node::ListItem {
2070                first_in_list,
2071                level,
2072                ..
2073            } = &run[k]
2074            else {
2075                continue;
2076            };
2077            if *level != base {
2078                continue; // nested item — handled inside add_list
2079            }
2080            if k > seg && *first_in_list {
2081                out.push(ref_value(self.add_list(&run[seg..k], parent)));
2082                seg = k;
2083            }
2084        }
2085        out.push(ref_value(self.add_list(&run[seg..], parent)));
2086    }
2087}
2088
2089fn level_of(node: &Node) -> u8 {
2090    match node {
2091        Node::ListItem { level, .. } => *level,
2092        _ => 0,
2093    }
2094}
2095
2096fn group_index(self_ref: &str) -> usize {
2097    self_ref.rsplit('/').next().unwrap().parse().unwrap()
2098}
2099
2100fn ref_index(self_ref: &str) -> Option<usize> {
2101    self_ref.rsplit('/').next()?.parse().ok()
2102}
2103
2104/// Merge the key/values of `extra` (an object) into `target` (an object).
2105fn merge(target: &mut Value, extra: Value) {
2106    if let (Some(t), Value::Object(e)) = (target.as_object_mut(), extra) {
2107        t.extend(e);
2108    }
2109}
2110
2111/// A JSON object of exactly these entries, in this order. The hot per-item
2112/// constructors use this instead of `json!`: the macro grows its map by
2113/// doubling (an 8-key item kept 14 slots) and deep-copies every interpolated
2114/// `Value` through `to_value`. On a long text-heavy PDF those item objects
2115/// *are* the export's footprint (the same reasoning as [`cell_value`]).
2116fn object<const N: usize>(entries: [(&str, Value); N]) -> Value {
2117    let mut m = serde_json::Map::with_capacity(N);
2118    for (k, v) in entries {
2119        m.insert(k.to_owned(), v);
2120    }
2121    Value::Object(m)
2122}
2123
2124/// A `{"$ref": r}` reference object.
2125fn ref_value(r: impl Into<String>) -> Value {
2126    object([("$ref", Value::String(r.into()))])
2127}
2128
2129/// Reverse [`crate`]'s Markdown text escaping (HTML entities + `\_`).
2130fn unescape_text(s: &str) -> String {
2131    s.replace("&lt;", "<")
2132        .replace("&gt;", ">")
2133        .replace("&amp;", "&")
2134        .replace("\\_", "_")
2135}
2136
2137/// 64-bit FNV-1a, a stand-in for docling's `binary_hash` (we lack the source bytes
2138/// at export time; the value only needs to be a stable u64).
2139fn fnv1a(s: &str) -> u64 {
2140    let mut h: u64 = 0xcbf29ce484222325;
2141    for b in s.bytes() {
2142        h ^= b as u64;
2143        h = h.wrapping_mul(0x100000001b3);
2144    }
2145    h
2146}
2147
2148#[cfg(test)]
2149mod tests {
2150    use crate::{
2151        CaptionParent, ContentLayer, DoclingDocument, ImageMode, Node, PictureImage, Table,
2152    };
2153    use serde_json::Value;
2154
2155    fn cell_at(text: &str, row: usize, col: usize, col_span: usize) -> crate::TableCell {
2156        crate::TableCell {
2157            text: text.into(),
2158            bbox: None,
2159            start_row: row,
2160            start_col: col,
2161            row_span: 1,
2162            col_span,
2163            column_header: row == 0,
2164            row_header: false,
2165            row_section: false,
2166        }
2167    }
2168
2169    /// First-class cells past the rows: rectangular rows are the grid and
2170    /// clip them (docling's `TableData.grid` on USPTO's overrun replicas);
2171    /// ragged rows — an xlsx table compacted by `skip_empty_cells` (#271) —
2172    /// are no grid, so the cells' extent sizes it and the omitted positions
2173    /// come back as empty filler cells in `grid` only.
2174    #[test]
2175    fn ragged_rows_take_their_grid_from_first_class_cells() {
2176        let cells = vec![
2177            cell_at("h0", 0, 0, 1),
2178            cell_at("h2", 0, 2, 1),
2179            cell_at("wide", 1, 0, 3),
2180        ];
2181        let export = |rows: Vec<Vec<String>>| -> Value {
2182            let mut doc = DoclingDocument::new("t");
2183            doc.push(Node::Table(Table {
2184                rows,
2185                cells: Some(cells.clone()),
2186                ..Table::default()
2187            }));
2188            let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2189            v["tables"][0]["data"].clone()
2190        };
2191        // Compacted rows: 2 and 3 wide, the cells say the grid is 3 wide.
2192        let data = export(vec![
2193            vec!["h0".into(), "h2".into()],
2194            vec!["wide".into(), "wide".into(), "wide".into()],
2195        ]);
2196        assert_eq!(data["num_rows"], 2);
2197        assert_eq!(data["num_cols"], 3);
2198        assert_eq!(data["table_cells"].as_array().unwrap().len(), 3);
2199        let row0: Vec<&str> = data["grid"][0]
2200            .as_array()
2201            .unwrap()
2202            .iter()
2203            .map(|c| c["text"].as_str().unwrap())
2204            .collect();
2205        assert_eq!(row0, ["h0", "", "h2"]);
2206        assert_eq!(data["table_cells"][2]["col_span"], 3);
2207        // Rectangular rows narrower than the cells: the rows win, the cells
2208        // are clipped to them exactly as before.
2209        let data = export(vec![
2210            vec!["h0".into(), "h2".into()],
2211            vec!["wide".into(), "wide".into()],
2212        ]);
2213        assert_eq!(data["num_cols"], 2);
2214        assert_eq!(data["grid"][0].as_array().unwrap().len(), 2);
2215    }
2216
2217    fn doc_with_image() -> DoclingDocument {
2218        let mut doc = DoclingDocument::new("t");
2219        doc.push(Node::Picture {
2220            caption: Some("Fig 1".into()),
2221            caption_href: None,
2222            image: Some(PictureImage {
2223                dpi: crate::PictureImage::DEFAULT_DPI,
2224                mimetype: "image/png".into(),
2225                width: 4,
2226                height: 2,
2227                data: b"foobar".to_vec(),
2228            }),
2229            classification: None,
2230            caption_parent: Default::default(),
2231            caption_location: None,
2232        });
2233        doc
2234    }
2235
2236    /// #402: a deck's speaker notes are content, and docling puts them in the
2237    /// JSON on the `notes` layer so a consumer reading only JSON can pick them
2238    /// out. Markdown still serializes the body layer alone, and page furniture
2239    /// stays out of the JSON, where docling does keep it.
2240    #[test]
2241    fn notes_layer_items_reach_the_json_but_furniture_does_not() {
2242        let mut doc = DoclingDocument::new("t");
2243        doc.push(Node::Heading {
2244            level: 1,
2245            text: "Slide One".into(),
2246        });
2247        doc.push(Node::Furniture {
2248            layer: ContentLayer::Notes,
2249            inner: Box::new(Node::Located {
2250                location: [0, 0, 0, 0],
2251                inner: Box::new(Node::Paragraph {
2252                    text: "Speaker note for slide 1.".into(),
2253                }),
2254            }),
2255        });
2256        doc.push(Node::Furniture {
2257            layer: ContentLayer::Furniture,
2258            inner: Box::new(Node::Paragraph {
2259                text: "page header".into(),
2260            }),
2261        });
2262
2263        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2264        let texts = v["texts"].as_array().unwrap();
2265        assert_eq!(
2266            texts
2267                .iter()
2268                .map(|t| (
2269                    t["label"].as_str().unwrap(),
2270                    t["content_layer"].as_str().unwrap(),
2271                    t["text"].as_str().unwrap()
2272                ))
2273                .collect::<Vec<_>>(),
2274            vec![
2275                ("title", "body", "Slide One"),
2276                ("text", "notes", "Speaker note for slide 1."),
2277            ],
2278            "the note is carried on its own layer; the furniture is not carried"
2279        );
2280        // The body layer is what Markdown serializes, so it does not change.
2281        assert_eq!(doc.export_to_markdown(), "# Slide One\n");
2282    }
2283
2284    /// #410: a backend that describes merged ranges only as continuation
2285    /// flags (xlsx `<mergeCell>`, docx `gridSpan`/`vMerge`) gets docling's
2286    /// one-`TableCell`-per-range JSON: the anchor's offsets and spans, the
2287    /// entry repeated across the grid positions it covers — not a 1×1 cell
2288    /// per position with the text copied into each.
2289    #[test]
2290    fn continuation_flags_become_spanning_cells() {
2291        let mut doc = DoclingDocument::new("t");
2292        // A1:C2 merged ("merged"), then a plain row underneath.
2293        let rows = vec![
2294            vec!["merged".to_string(), "merged".into(), "merged".into()],
2295            vec!["merged".to_string(), "merged".into(), "merged".into()],
2296            vec!["a".to_string(), "b".into(), "c".into()],
2297        ];
2298        doc.push(Node::Table(crate::Table {
2299            rows,
2300            location: None,
2301            structure: Some(crate::TableStructure {
2302                header_row: vec![true, false, false],
2303                col_continuation: vec![
2304                    vec![false, true, true],
2305                    vec![false, true, true],
2306                    vec![false, false, false],
2307                ],
2308                row_continuation: vec![
2309                    vec![false, false, false],
2310                    vec![true, true, true],
2311                    vec![false, false, false],
2312                ],
2313                row_header: Vec::new(),
2314                col_header: Vec::new(),
2315            }),
2316            cell_blocks: None,
2317            cells: None,
2318            caption: None,
2319            caption_parent: Default::default(),
2320            caption_location: None,
2321        }));
2322        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2323        let data = &v["tables"][0]["data"];
2324        assert_eq!(data["num_rows"], 3);
2325        assert_eq!(data["num_cols"], 3);
2326        let cells = data["table_cells"].as_array().unwrap();
2327        assert_eq!(
2328            cells.len(),
2329            4,
2330            "one cell for the range, three for the plain row"
2331        );
2332        assert_eq!(
2333            cells[0],
2334            serde_json::json!({
2335                "row_span": 2, "col_span": 3,
2336                "start_row_offset_idx": 0, "end_row_offset_idx": 2,
2337                "start_col_offset_idx": 0, "end_col_offset_idx": 3,
2338                "text": "merged", "column_header": true, "row_header": false,
2339                "row_section": false, "fillable": false,
2340            })
2341        );
2342        assert_eq!(cells[1]["text"], "a");
2343        assert_eq!(cells[1]["row_span"], 1);
2344        assert_eq!(cells[1]["column_header"], false);
2345        // The grid repeats the range's entry at every position it covers.
2346        let grid = data["grid"].as_array().unwrap();
2347        assert_eq!(grid.len(), 3);
2348        for (r, row) in grid.iter().take(2).enumerate() {
2349            for (c, cell) in row.as_array().unwrap().iter().enumerate() {
2350                assert_eq!(*cell, cells[0], "grid[{r}][{c}]");
2351            }
2352        }
2353        assert_eq!(grid[2][2]["text"], "c");
2354    }
2355
2356    /// A [`Node::Prov`] wrapper is docling's provenance verbatim — the exact
2357    /// box in a top-left origin, the backend's charspan — and the page marker
2358    /// before it sizes the page; a chart's caption becomes a sibling of the
2359    /// picture in the container, listed first, sharing the chart's box with
2360    /// a charspan over its text. That is the JSON shape of an XLSX sheet.
2361    #[test]
2362    fn exact_provenance_pages_and_chart_captions_follow_docling() {
2363        let mut doc = DoclingDocument::new("t");
2364        doc.push(Node::PageInfo {
2365            page_no: 1,
2366            width: 3.0,
2367            height: 4.0,
2368        });
2369        let table = crate::Table {
2370            rows: vec![vec!["a".to_string(), "b".into()]],
2371            ..Default::default()
2372        };
2373        doc.push(Node::Group {
2374            label: "sheet".into(),
2375            name: Some("Data".into()),
2376            layer: None,
2377            children: vec![
2378                // Node order is the position-sorted one; creation order (the
2379                // `seq`) had the chart first — so the chart is `#/pictures/0`
2380                // *and* its caption `#/texts/0`, while the table stays the
2381                // group's first child.
2382                Node::Prov {
2383                    page_no: 1,
2384                    bbox: [0.0, 0.0, 3.0, 4.0],
2385                    charspan: [0, 0],
2386                    seq: Some(1),
2387                    inner: Box::new(Node::Table(table.clone())),
2388                },
2389                Node::Prov {
2390                    page_no: 1,
2391                    bbox: [0.0, 1.0, 1.0, 1.0],
2392                    charspan: [0, 0],
2393                    seq: Some(0),
2394                    inner: Box::new(Node::Chart {
2395                        kind: "bar_chart".into(),
2396                        table,
2397                        caption: Some("Sales".into()),
2398                        location: Some([0, 128, 170, 128]),
2399                    }),
2400                },
2401            ],
2402        });
2403        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2404        assert_eq!(
2405            v["pages"],
2406            serde_json::json!({"1": {"size": {"width": 3.0, "height": 4.0}, "page_no": 1}})
2407        );
2408        assert_eq!(
2409            v["tables"][0]["prov"],
2410            serde_json::json!([{
2411                "page_no": 1,
2412                "bbox": {"l": 0.0, "t": 0.0, "r": 3.0, "b": 4.0, "coord_origin": "TOPLEFT"},
2413                "charspan": [0, 0],
2414            }])
2415        );
2416        assert_eq!(v["tables"][0]["data"]["orientation"], "rot_0");
2417        // The caption is the group's child *before* the picture, parented to
2418        // the group, and referenced by the picture.
2419        let sheet = &v["groups"][0];
2420        assert_eq!(
2421            sheet["children"],
2422            serde_json::json!([
2423                {"$ref": "#/tables/0"}, {"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}
2424            ])
2425        );
2426        let cap = &v["texts"][0];
2427        assert_eq!(cap["label"], "caption");
2428        assert_eq!(cap["parent"], serde_json::json!({"$ref": "#/groups/0"}));
2429        assert_eq!(cap["prov"][0]["charspan"], serde_json::json!([0, 5]));
2430        assert_eq!(cap["prov"][0]["bbox"]["b"], 1.0);
2431        let pic = &v["pictures"][0];
2432        assert_eq!(pic["captions"], serde_json::json!([{"$ref": "#/texts/0"}]));
2433        assert_eq!(pic["prov"][0]["charspan"], serde_json::json!([0, 0]));
2434        assert_eq!(pic["prov"][0]["bbox"]["coord_origin"], "TOPLEFT");
2435        assert_eq!(
2436            pic["meta"]["classification"]["predictions"][0]["class_name"],
2437            "bar_chart"
2438        );
2439        assert_eq!(pic["meta"]["tabular_chart"]["chart_data"]["num_cols"], 2);
2440    }
2441
2442    /// An all-zero location is the "no geometry" sentinel — a slide's speaker
2443    /// notes carry one — and docling writes it as a zero bbox, not as a box
2444    /// spanning the whole page, which is what denormalizing the grid gives.
2445    #[test]
2446    fn a_zero_location_is_a_zero_bbox_not_the_whole_page() {
2447        let mut doc = DoclingDocument::new("t");
2448        doc.push(Node::PageInfo {
2449            page_no: 1,
2450            width: 12192000.0,
2451            height: 6858000.0,
2452        });
2453        doc.push(Node::Furniture {
2454            layer: ContentLayer::Notes,
2455            inner: Box::new(Node::Located {
2456                location: [0, 0, 0, 0],
2457                inner: Box::new(Node::Paragraph {
2458                    text: "a note".into(),
2459                }),
2460            }),
2461        });
2462        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2463        let prov = &v["texts"][0]["prov"][0];
2464        assert_eq!(prov["page_no"], 1);
2465        assert_eq!(prov["charspan"], serde_json::json!([0, 6]));
2466        assert_eq!(
2467            prov["bbox"],
2468            serde_json::json!({"l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT"})
2469        );
2470        // The page itself is recorded at its true size.
2471        assert_eq!(
2472            v["pages"]["1"]["size"],
2473            serde_json::json!({"width": 12192000.0, "height": 6858000.0})
2474        );
2475    }
2476
2477    /// #171: PageInfo markers become the `pages` map, and `Located` wrappers /
2478    /// node-level locations become per-item `prov` — the 0–511 grid
2479    /// denormalized against the page into BOTTOMLEFT points. Without markers
2480    /// (every declarative backend) the JSON stays exactly as before: empty
2481    /// `pages`, `prov: []` even for located nodes.
2482    /// #609: a PDF caption and a checkbox item carry their own page and box.
2483    /// docling's reading-order model gives every caption the cluster it came
2484    /// from (`_add_caption_or_footnote`), not its picture's or table's, and
2485    /// a checkbox is a text item like any other.
2486    #[test]
2487    fn captions_and_checkboxes_carry_their_own_prov() {
2488        let mut doc = DoclingDocument::new("t");
2489        doc.push(Node::PageInfo {
2490            page_no: 1,
2491            width: 512.0,
2492            height: 512.0,
2493        });
2494        doc.push(Node::Located {
2495            location: [64, 64, 448, 256],
2496            inner: Box::new(Node::Picture {
2497                caption: Some("Figure 1".into()),
2498                caption_href: None,
2499                image: None,
2500                classification: None,
2501                caption_parent: CaptionParent::Item,
2502                caption_location: Some([64, 264, 448, 280]),
2503            }),
2504        });
2505        doc.push(Node::PageInfo {
2506            page_no: 2,
2507            width: 512.0,
2508            height: 512.0,
2509        });
2510        doc.push(Node::Located {
2511            location: [64, 120, 448, 300],
2512            inner: Box::new(Node::Table(Table {
2513                rows: vec![vec!["a".into()]],
2514                caption: Some("Table 1".into()),
2515                caption_parent: CaptionParent::Item,
2516                caption_location: Some([64, 100, 300, 112]),
2517                ..Table::default()
2518            })),
2519        });
2520        doc.push(Node::Located {
2521            location: [64, 320, 200, 332],
2522            inner: Box::new(Node::CheckboxItem {
2523                checked: false,
2524                text: "First option".into(),
2525            }),
2526        });
2527        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2528        let texts = v["texts"].as_array().unwrap();
2529        let by_text = |t: &str| texts.iter().find(|x| x["text"] == t).expect(t).clone();
2530        let bbox = |p: &Value| {
2531            let b = &p["prov"][0]["bbox"];
2532            [&b["l"], &b["t"], &b["r"], &b["b"]].map(|x| x.as_f64().unwrap())
2533        };
2534        // Figure 1: its own box (grid == points on a 512 page, flipped to
2535        // BOTTOMLEFT), not the picture's.
2536        let fig = by_text("Figure 1");
2537        assert_eq!(fig["prov"][0]["page_no"], 1);
2538        assert_eq!(bbox(&fig), [64.0, 248.0, 448.0, 232.0]);
2539        assert_eq!(fig["prov"][0]["charspan"], serde_json::json!([0, 8]));
2540        assert_eq!(fig["parent"]["$ref"], "#/pictures/0");
2541        assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
2542        // Table 1 on page 2, its own box; the table keeps its own.
2543        let tab = by_text("Table 1");
2544        assert_eq!(tab["prov"][0]["page_no"], 2);
2545        assert_eq!(bbox(&tab), [64.0, 412.0, 300.0, 400.0]);
2546        assert_eq!(v["tables"][0]["prov"][0]["bbox"]["t"], 392.0);
2547        // The checkbox item.
2548        let cb = by_text("First option");
2549        assert_eq!(cb["label"], "checkbox_unselected");
2550        assert_eq!(cb["prov"][0]["page_no"], 2);
2551        assert_eq!(bbox(&cb), [64.0, 192.0, 200.0, 180.0]);
2552        assert!(texts
2553            .iter()
2554            .all(|t| !t["prov"].as_array().unwrap().is_empty()));
2555
2556        // Without a caption box (every declarative backend) the caption
2557        // still has no prov, and nothing else moves.
2558        let mut plain = DoclingDocument::new("t");
2559        plain.push(Node::PageInfo {
2560            page_no: 1,
2561            width: 512.0,
2562            height: 512.0,
2563        });
2564        plain.push(Node::Located {
2565            location: [64, 64, 448, 256],
2566            inner: Box::new(Node::Picture {
2567                caption: Some("Figure 1".into()),
2568                caption_href: None,
2569                image: None,
2570                classification: None,
2571                caption_parent: CaptionParent::Item,
2572                caption_location: None,
2573            }),
2574        });
2575        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
2576        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
2577        assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
2578    }
2579
2580    #[test]
2581    fn page_markers_produce_pages_and_prov() {
2582        let mut doc = DoclingDocument::new("t");
2583        doc.push(Node::PageInfo {
2584            page_no: 1,
2585            width: 512.0,
2586            height: 1024.0,
2587        });
2588        doc.push(Node::Located {
2589            location: [128, 64, 256, 128], // quarter/eighth points of the grid
2590            inner: Box::new(Node::Paragraph {
2591                text: "hello".into(),
2592            }),
2593        });
2594        doc.push(Node::Table(Table {
2595            rows: vec![vec!["a".into()]],
2596            location: Some([0, 0, 512, 512]),
2597            ..Table::default()
2598        }));
2599        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2600        assert_eq!(v["pages"]["1"]["page_no"], 1);
2601        assert_eq!(v["pages"]["1"]["size"]["width"], 512.0);
2602        assert_eq!(v["pages"]["1"]["size"]["height"], 1024.0);
2603        // 512-wide page: grid x scales 1:1; 1024-high: grid y doubles, then
2604        // flips to the BOTTOMLEFT origin (t from grid-top 64 → 1024-128=896).
2605        let prov = &v["texts"][0]["prov"][0];
2606        assert_eq!(prov["page_no"], 1);
2607        assert_eq!(prov["bbox"]["l"], 128.0);
2608        assert_eq!(prov["bbox"]["t"], 896.0);
2609        assert_eq!(prov["bbox"]["r"], 256.0);
2610        assert_eq!(prov["bbox"]["b"], 768.0);
2611        assert_eq!(prov["bbox"]["coord_origin"], "BOTTOMLEFT");
2612        assert_eq!(prov["charspan"][1], 5);
2613        // The table adopts its own location field; charspan is [0, 0].
2614        let tprov = &v["tables"][0]["prov"][0];
2615        assert_eq!(tprov["bbox"]["t"], 1024.0);
2616        assert_eq!(tprov["bbox"]["b"], 0.0);
2617        assert_eq!(tprov["charspan"][1], 0);
2618
2619        // No markers → the pre-#171 shape, byte for byte.
2620        let mut plain = DoclingDocument::new("t");
2621        plain.push(Node::Located {
2622            location: [1, 2, 3, 4],
2623            inner: Box::new(Node::Paragraph { text: "x".into() }),
2624        });
2625        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
2626        assert_eq!(v["pages"], serde_json::json!({}));
2627        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
2628    }
2629
2630    /// PDF page headers/footers reach the JSON as docling writes them:
2631    /// body-parented text items on the furniture layer, with their box.
2632    #[test]
2633    fn page_furniture_becomes_furniture_layer_text() {
2634        let mut doc = DoclingDocument::new("t");
2635        doc.push(Node::PageInfo {
2636            page_no: 1,
2637            width: 512.0,
2638            height: 512.0,
2639        });
2640        doc.push(Node::PageFurniture {
2641            footer: false,
2642            location: [10, 0, 100, 20],
2643            text: "Chapter 1".into(),
2644        });
2645        doc.push(Node::Paragraph {
2646            text: "body".into(),
2647        });
2648        doc.push(Node::PageFurniture {
2649            footer: true,
2650            location: [400, 490, 500, 512],
2651            text: "1.10.2".into(),
2652        });
2653        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2654        let texts = v["texts"].as_array().unwrap();
2655        assert_eq!(texts.len(), 3);
2656        assert_eq!(texts[0]["label"], "page_header");
2657        assert_eq!(texts[0]["content_layer"], "furniture");
2658        assert_eq!(texts[0]["parent"]["$ref"], "#/body");
2659        assert_eq!(texts[0]["prov"][0]["bbox"]["l"], 10.0);
2660        assert_eq!(texts[1]["content_layer"], "body");
2661        assert_eq!(texts[1]["prov"], serde_json::json!([]));
2662        assert_eq!(texts[2]["label"], "page_footer");
2663        assert_eq!(texts[2]["text"], "1.10.2");
2664        assert_eq!(texts[2]["content_layer"], "furniture");
2665    }
2666
2667    #[test]
2668    fn picture_image_in_markdown_modes_and_json() {
2669        let doc = doc_with_image();
2670        // placeholder (default) ignores the image
2671        assert!(doc.export_to_markdown().contains("<!-- image -->"));
2672        // embedded → base64 data URI (b"foobar" → "Zm9vYmFy")
2673        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Embedded, "artifacts");
2674        assert!(
2675            md.contains("![Image](data:image/png;base64,Zm9vYmFy)"),
2676            "got:\n{md}"
2677        );
2678        assert!(files.is_empty());
2679        // referenced → file link + collected bytes
2680        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Referenced, "artifacts");
2681        assert!(
2682            md.contains("![Image](artifacts/image_000000.png)"),
2683            "got:\n{md}"
2684        );
2685        assert_eq!(
2686            files,
2687            vec![("artifacts/image_000000.png".to_string(), b"foobar".to_vec())]
2688        );
2689        // JSON carries the ImageRef (data URI + size — floats, as docling's
2690        // `Size` is — placed before `annotations`).
2691        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2692        assert_eq!(v["pictures"][0]["image"]["mimetype"], "image/png");
2693        assert_eq!(v["pictures"][0]["image"]["size"]["width"], 4.0);
2694        let keys: Vec<&str> = v["pictures"][0]
2695            .as_object()
2696            .unwrap()
2697            .keys()
2698            .map(String::as_str)
2699            .collect();
2700        assert_eq!(&keys[keys.len() - 2..], ["image", "annotations"]);
2701        assert_eq!(
2702            v["pictures"][0]["image"]["uri"],
2703            "data:image/png;base64,Zm9vYmFy"
2704        );
2705    }
2706
2707    #[test]
2708    fn exports_docling_schema() {
2709        let mut doc = DoclingDocument::new("t");
2710        doc.push(Node::Heading {
2711            level: 1,
2712            text: "Title".into(),
2713        });
2714        doc.push(Node::Heading {
2715            level: 2,
2716            text: "Sec".into(),
2717        });
2718        doc.push(Node::Paragraph {
2719            text: "Body &amp; more".into(),
2720        }); // markdown-escaped
2721        doc.push(Node::ListItem {
2722            ordered: false,
2723            number: 0,
2724            first_in_list: true,
2725            text: "one".into(),
2726            level: 0,
2727            marker: None,
2728            location: None,
2729            dclx: None,
2730            href: None,
2731            layer: None,
2732        });
2733        doc.push(Node::ListItem {
2734            ordered: false,
2735            number: 0,
2736            first_in_list: false,
2737            text: "two".into(),
2738            level: 0,
2739            marker: None,
2740            location: None,
2741            dclx: None,
2742            href: None,
2743            layer: None,
2744        });
2745        doc.push(Node::Table(Table {
2746            rows: vec![vec!["A".into(), "B".into()]],
2747            location: None,
2748            structure: None,
2749            cell_blocks: None,
2750            cells: None,
2751            caption: None,
2752            caption_parent: Default::default(),
2753            caption_location: None,
2754        }));
2755
2756        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2757        assert_eq!(v["schema_name"], "DoclingDocument");
2758        assert_eq!(v["version"], "1.10.0");
2759        assert_eq!(v["texts"][0]["label"], "title");
2760        assert_eq!(v["texts"][1]["label"], "section_header");
2761        assert_eq!(v["texts"][1]["level"], 1); // heading level 2 → docling level 1
2762        assert_eq!(v["texts"][2]["text"], "Body & more"); // un-escaped for the wire format
2763                                                          // consecutive list items fold into one list group, parented to it
2764        assert_eq!(v["groups"][0]["label"], "list");
2765        assert_eq!(v["groups"][0]["children"].as_array().unwrap().len(), 2);
2766        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
2767        assert_eq!(v["texts"][3]["marker"], "-");
2768        // table grid + header flag
2769        assert_eq!(v["tables"][0]["data"]["num_cols"], 2);
2770        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["column_header"], true);
2771    }
2772    /// A named group on a non-body layer — docling's hidden spreadsheet sheet:
2773    /// the group carries the sheet's name and the `invisible` layer, and every
2774    /// item inside it carries the layer too.
2775    #[test]
2776    fn a_layered_group_stamps_its_whole_subtree() {
2777        let doc = DoclingDocument {
2778            name: "s".into(),
2779            nodes: vec![
2780                Node::Group {
2781                    label: "sheet".into(),
2782                    name: Some("Sheet1".into()),
2783                    layer: None,
2784                    children: vec![Node::Paragraph {
2785                        text: "visible".into(),
2786                    }],
2787                },
2788                Node::Group {
2789                    label: "sheet".into(),
2790                    name: Some("Sheet2".into()),
2791                    layer: Some(ContentLayer::Invisible),
2792                    children: vec![Node::Paragraph {
2793                        text: "hidden".into(),
2794                    }],
2795                },
2796            ],
2797            ..DoclingDocument::new("s")
2798        };
2799        let v = crate::json::to_json(&doc);
2800        assert_eq!(v["groups"][0]["label"], "sheet");
2801        assert_eq!(v["groups"][0]["name"], "Sheet1");
2802        assert_eq!(v["groups"][0]["content_layer"], "body");
2803        assert_eq!(v["texts"][0]["content_layer"], "body");
2804        assert_eq!(v["groups"][1]["name"], "Sheet2");
2805        assert_eq!(v["groups"][1]["content_layer"], "invisible");
2806        assert_eq!(v["texts"][1]["content_layer"], "invisible");
2807        // The group's children are the items, and the body holds the groups.
2808        assert_eq!(v["groups"][1]["children"][0]["$ref"], "#/texts/1");
2809        assert_eq!(v["body"]["children"][1]["$ref"], "#/groups/1");
2810    }
2811
2812    /// A backend-built item tree is serialized as it is: items numbered in
2813    /// creation order per bucket (a field region's part texts included), the
2814    /// tree's parents / children / layers, docling's field order for
2815    /// `formatting`, `hyperlink`, `level`, `enumerated`/`marker`, a rich
2816    /// cell's `ref` on `table_cells` only, raw cell text.
2817    /// The DOCX tree's extras: an item `delete`d (docling's `delete_items`,
2818    /// the spacer between two items of a resumed list) is neither written nor
2819    /// numbered, `comments` back-refs sit between `prov` and `orig`, and a
2820    /// chart picture carries `classification` plus `tabular_chart`.
2821    #[test]
2822    fn deleted_items_comment_refs_and_chart_meta_in_the_tree() {
2823        use crate::tree::{ItemTree, TreeKind};
2824        let mut t = ItemTree::default();
2825        let text = |txt: &str| TreeKind::Text {
2826            label: "text".into(),
2827            text: txt.into(),
2828            orig: None,
2829            formatting: None,
2830            hyperlink: None,
2831            level: None,
2832            list: None,
2833        };
2834        let a = t.add(None, None, text("a"));
2835        let blank = t.add(None, None, text(""));
2836        let b = t.add(None, None, text("b"));
2837        t.delete(blank);
2838        let group = t.add(
2839            None,
2840            Some(ContentLayer::Notes),
2841            TreeKind::Group {
2842                label: "comment_section".into(),
2843                name: "comment-0".into(),
2844            },
2845        );
2846        t.add(Some(group), Some(ContentLayer::Notes), text("note"));
2847        t.items[a].comments.push(group);
2848        t.add(
2849            None,
2850            None,
2851            TreeKind::Picture {
2852                captions: Vec::new(),
2853                image: None,
2854                classification: Some("bar_chart".into()),
2855                confidence: None,
2856                chart: Some(Table {
2857                    rows: vec![vec!["".into(), "s".into()], vec!["c".into(), "1".into()]],
2858                    ..Table::default()
2859                }),
2860                dpi: None,
2861            },
2862        );
2863        assert_eq!(t.last_text(), Some(4), "the note; the blank is skipped");
2864        assert_eq!(t.bucket_index(b), 1, "numbered past the deleted item");
2865        let mut doc = DoclingDocument::new("t");
2866        doc.tree = Some(t);
2867        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2868        let texts = v["texts"].as_array().unwrap();
2869        assert_eq!(texts.len(), 3);
2870        assert_eq!(texts[1]["text"], "b");
2871        assert_eq!(texts[1]["self_ref"], "#/texts/1");
2872        assert_eq!(
2873            v["body"]["children"],
2874            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}, {"$ref": "#/groups/0"}, {"$ref": "#/pictures/0"}])
2875        );
2876        let keys: Vec<&str> = texts[0]
2877            .as_object()
2878            .unwrap()
2879            .keys()
2880            .map(String::as_str)
2881            .collect();
2882        assert_eq!(
2883            keys,
2884            vec![
2885                "self_ref",
2886                "parent",
2887                "children",
2888                "content_layer",
2889                "label",
2890                "prov",
2891                "comments",
2892                "orig",
2893                "text"
2894            ]
2895        );
2896        assert_eq!(
2897            texts[0]["comments"],
2898            serde_json::json!([{"$ref": "#/groups/0"}])
2899        );
2900        assert!(texts[1].get("comments").is_none());
2901        let meta = &v["pictures"][0]["meta"];
2902        assert_eq!(
2903            meta["classification"]["predictions"][0]["class_name"],
2904            "bar_chart"
2905        );
2906        assert_eq!(meta["tabular_chart"]["chart_data"]["num_rows"], 2);
2907    }
2908
2909    /// A tree item's `TreeProv` is written verbatim — a raw EMU box with
2910    /// whatever origin tag the backend set (`BOTTOMLEFT` here) and a per-item
2911    /// charspan, a note's zero `TOPLEFT` box — a picture's `image.dpi` is the file's when the backend
2912    /// read one, an item without provenance writes `prov: []`, and the page
2913    /// map still comes from the flat stream's markers.
2914    #[test]
2915    fn tree_items_carry_exact_provenance_and_dpi() {
2916        use crate::tree::{ItemTree, TreeKind, TreeProv};
2917        let text = |label: &str, t: &str| TreeKind::Text {
2918            label: label.into(),
2919            text: t.into(),
2920            orig: None,
2921            formatting: None,
2922            hyperlink: None,
2923            level: None,
2924            list: None,
2925        };
2926        let mut t = ItemTree::default();
2927        let slide = t.add(
2928            None,
2929            None,
2930            TreeKind::Group {
2931                label: "chapter".into(),
2932                name: "slide-0".into(),
2933            },
2934        );
2935        t.add_with_prov(
2936            Some(slide),
2937            None,
2938            text("paragraph", "héllo"),
2939            TreeProv {
2940                page_no: 1,
2941                bbox: [914400.0, 1828800.0, 2743200.0, 457200.0],
2942                bottom_left: true,
2943                charspan: [0, 5],
2944            },
2945        );
2946        t.add_with_prov(
2947            Some(slide),
2948            None,
2949            TreeKind::Picture {
2950                captions: Vec::new(),
2951                image: Some(crate::PictureImage {
2952                    dpi: crate::PictureImage::DEFAULT_DPI,
2953                    mimetype: "image/png".into(),
2954                    width: 2,
2955                    height: 2,
2956                    data: vec![0],
2957                }),
2958                classification: None,
2959                confidence: None,
2960                chart: None,
2961                dpi: Some(300),
2962            },
2963            TreeProv {
2964                page_no: 1,
2965                bbox: [0.0; 4],
2966                bottom_left: false,
2967                charspan: [0, 0],
2968            },
2969        );
2970        t.add(
2971            Some(slide),
2972            Some(ContentLayer::Notes),
2973            text("text", "no geometry"),
2974        );
2975        let mut doc = DoclingDocument::new("t");
2976        doc.push(Node::PageInfo {
2977            page_no: 1,
2978            width: 9144000.0,
2979            height: 6858000.0,
2980        });
2981        doc.tree = Some(t);
2982        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2983        assert_eq!(
2984            v["texts"][0]["prov"],
2985            serde_json::json!([{
2986                "page_no": 1,
2987                "bbox": { "l": 914400.0, "t": 1828800.0, "r": 2743200.0, "b": 457200.0, "coord_origin": "BOTTOMLEFT" },
2988                "charspan": [0, 5],
2989            }])
2990        );
2991        assert_eq!(v["texts"][0]["label"], "paragraph");
2992        assert_eq!(
2993            v["pictures"][0]["prov"][0]["bbox"]["coord_origin"],
2994            "TOPLEFT"
2995        );
2996        assert_eq!(v["pictures"][0]["image"]["dpi"], 300);
2997        assert_eq!(v["texts"][1]["prov"], serde_json::json!([]));
2998        assert_eq!(v["texts"][1]["content_layer"], "notes");
2999        assert_eq!(v["pages"]["1"]["size"]["width"], 9144000.0);
3000        assert_eq!(v["pages"]["1"]["page_no"], 1);
3001    }
3002
3003    /// docling-core's `validate_document` clamps every provenance box (and a
3004    /// one-page table's cell boxes) into its page — the state every
3005    /// `ConversionResult` leaves a document in, so the state docling's JSON
3006    /// shows. A box on a page the document does not describe is left alone.
3007    #[test]
3008    fn provenance_boxes_are_clamped_to_their_page() {
3009        let mut doc = DoclingDocument::new("t");
3010        doc.push(Node::PageInfo {
3011            page_no: 1,
3012            width: 10.0,
3013            height: 8.0,
3014        });
3015        doc.push(Node::Prov {
3016            page_no: 1,
3017            bbox: [-1.0, 2.0, 12.0, 9.5],
3018            charspan: [0, 1],
3019            seq: None,
3020            inner: Box::new(Node::Paragraph { text: "x".into() }),
3021        });
3022        let mut table = Table {
3023            rows: vec![vec!["a".into()]],
3024            ..Table::default()
3025        };
3026        table.cells = Some(vec![crate::TableCell {
3027            text: "a".into(),
3028            bbox: Some([1.0, 1.0, 11.0, 9.0]),
3029            start_row: 0,
3030            start_col: 0,
3031            row_span: 1,
3032            col_span: 1,
3033            column_header: false,
3034            row_header: false,
3035            row_section: false,
3036        }]);
3037        doc.push(Node::Prov {
3038            page_no: 1,
3039            bbox: [0.0, 0.0, 10.0, 8.0],
3040            charspan: [0, 0],
3041            seq: None,
3042            inner: Box::new(Node::Table(table)),
3043        });
3044        doc.push(Node::Prov {
3045            page_no: 7,
3046            bbox: [-5.0, 0.0, 50.0, 50.0],
3047            charspan: [0, 1],
3048            seq: None,
3049            inner: Box::new(Node::Paragraph { text: "y".into() }),
3050        });
3051        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3052        assert_eq!(
3053            v["texts"][0]["prov"][0]["bbox"],
3054            serde_json::json!({ "l": 0.0, "t": 2.0, "r": 10.0, "b": 8.0, "coord_origin": "TOPLEFT" })
3055        );
3056        let cell = &v["tables"][0]["data"]["table_cells"][0]["bbox"];
3057        assert_eq!(
3058            (cell["l"].as_f64(), cell["r"].as_f64(), cell["b"].as_f64()),
3059            (Some(1.0), Some(10.0), Some(8.0))
3060        );
3061        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["bbox"]["r"], 10.0);
3062        assert_eq!(
3063            v["texts"][1]["prov"][0]["bbox"]["r"], 50.0,
3064            "page 7 is not described"
3065        );
3066    }
3067
3068    /// docling-core's `validate_misplaced_list_items`, as it re-homes list
3069    /// items outside a `list` group (#527) — the refs below are what
3070    /// docling-core 2.99 writes for the same document: the two consecutive
3071    /// items on the body share a group, each item in another group gets its
3072    /// own, the runs are handled last-first (new groups and re-added texts
3073    /// numbered in that order), and a well-placed item is left alone.
3074    #[test]
3075    fn misplaced_list_items_are_wrapped_like_docling_core() {
3076        use crate::tree::{ItemTree, ListMeta, TreeKind};
3077        let mut t = ItemTree::default();
3078        let li = |txt: &str| TreeKind::Text {
3079            label: "list_item".into(),
3080            text: txt.into(),
3081            orig: None,
3082            formatting: None,
3083            hyperlink: None,
3084            level: None,
3085            list: Some(ListMeta {
3086                enumerated: false,
3087                marker: "-".into(),
3088            }),
3089        };
3090        let group = |label: &str, name: &str| TreeKind::Group {
3091            label: label.into(),
3092            name: name.into(),
3093        };
3094        t.add(None, None, li("A"));
3095        t.add(None, None, li("B"));
3096        t.add(
3097            None,
3098            None,
3099            TreeKind::Text {
3100                label: "text".into(),
3101                text: "plain".into(),
3102                orig: None,
3103                formatting: None,
3104                hyperlink: None,
3105                level: None,
3106                list: None,
3107            },
3108        );
3109        let cell = t.add(None, None, group("unspecified", "cell"));
3110        t.add(Some(cell), None, li("C"));
3111        t.add(Some(cell), None, li("D"));
3112        let list = t.add(None, None, group("list", "list"));
3113        t.add(Some(list), None, li("E"));
3114        let doc = DoclingDocument {
3115            tree: Some(t),
3116            ..DoclingDocument::new("t")
3117        };
3118        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3119        let refs = |x: &Value| -> Vec<String> {
3120            x.as_array()
3121                .unwrap()
3122                .iter()
3123                .map(|r| r["$ref"].as_str().unwrap().to_string())
3124                .collect()
3125        };
3126        assert_eq!(
3127            refs(&v["body"]["children"]),
3128            ["#/groups/4", "#/texts/0", "#/groups/0", "#/groups/1"]
3129        );
3130        assert_eq!(
3131            refs(&v["groups"][0]["children"]),
3132            ["#/groups/3", "#/groups/2"]
3133        );
3134        for (g, name, kids) in [
3135            (2, "group", vec!["#/texts/2"]),
3136            (3, "group", vec!["#/texts/3"]),
3137            (4, "group", vec!["#/texts/4", "#/texts/5"]),
3138            (1, "list", vec!["#/texts/1"]),
3139        ] {
3140            assert_eq!(v["groups"][g]["label"], "list");
3141            assert_eq!(v["groups"][g]["name"], name);
3142            assert_eq!(refs(&v["groups"][g]["children"]), kids);
3143        }
3144        let texts: Vec<&str> = v["texts"]
3145            .as_array()
3146            .unwrap()
3147            .iter()
3148            .map(|t| t["text"].as_str().unwrap())
3149            .collect();
3150        assert_eq!(texts, ["plain", "E", "D", "C", "A", "B"]);
3151        assert_eq!(v["texts"][2]["parent"]["$ref"], "#/groups/2");
3152    }
3153
3154    /// A child the tree still lists after deleting it is left out instead
3155    /// of written as `"$ref": ""`, which docling-core rejects (#527).
3156    #[test]
3157    fn a_deleted_child_is_not_written_as_an_empty_ref() {
3158        use crate::tree::{ItemTree, TreeKind};
3159        let mut t = ItemTree::default();
3160        let text = |txt: &str| TreeKind::Text {
3161            label: "text".into(),
3162            text: txt.into(),
3163            orig: None,
3164            formatting: None,
3165            hyperlink: None,
3166            level: None,
3167            list: None,
3168        };
3169        let g = t.add(
3170            None,
3171            None,
3172            TreeKind::Group {
3173                label: "unspecified".into(),
3174                name: "g".into(),
3175            },
3176        );
3177        let gone = t.add(Some(g), None, text(""));
3178        t.add(Some(g), None, text("kept"));
3179        t.items[gone].deleted = true; // still in `g`'s children
3180        let doc = DoclingDocument {
3181            tree: Some(t),
3182            ..DoclingDocument::new("t")
3183        };
3184        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3185        assert_eq!(
3186            v["groups"][0]["children"],
3187            serde_json::json!([{"$ref": "#/texts/0"}])
3188        );
3189    }
3190
3191    #[test]
3192    fn a_backend_item_tree_is_written_verbatim() {
3193        use crate::tree::{Formatting, ItemTree, ListMeta, TreeKind};
3194        let mut t = ItemTree::default();
3195        let text = |label: &str, txt: &str| TreeKind::Text {
3196            label: label.into(),
3197            text: txt.into(),
3198            orig: None,
3199            formatting: None,
3200            hyperlink: None,
3201            level: None,
3202            list: None,
3203        };
3204        let title = t.add(None, Some(ContentLayer::Furniture), text("title", "Page"));
3205        let h = t.add(None, None, text("title", "Heading"));
3206        let group = t.add(
3207            Some(h),
3208            None,
3209            TreeKind::Group {
3210                label: "inline".into(),
3211                name: "group".into(),
3212            },
3213        );
3214        t.add(
3215            Some(group),
3216            None,
3217            TreeKind::Text {
3218                label: "text".into(),
3219                text: "bold".into(),
3220                orig: None,
3221                formatting: Some(Formatting {
3222                    bold: true,
3223                    ..Formatting::default()
3224                }),
3225                hyperlink: Some("https://example.com/".into()),
3226                level: None,
3227                list: None,
3228            },
3229        );
3230        t.add(
3231            Some(group),
3232            None,
3233            TreeKind::Code {
3234                text: "x = 1".into(),
3235                orig: None,
3236                language: Some("python".into()),
3237                formatting: None,
3238                hyperlink: None,
3239            },
3240        );
3241        let sub = t.add(
3242            Some(h),
3243            None,
3244            TreeKind::Text {
3245                label: "section_header".into(),
3246                text: "Sub".into(),
3247                orig: Some("Sub\u{2019}".into()),
3248                formatting: None,
3249                hyperlink: None,
3250                level: Some(1),
3251                list: None,
3252            },
3253        );
3254        let item = t.add(
3255            Some(sub),
3256            None,
3257            TreeKind::Text {
3258                label: "list_item".into(),
3259                text: "item".into(),
3260                orig: None,
3261                formatting: None,
3262                hyperlink: None,
3263                level: None,
3264                list: Some(ListMeta {
3265                    enumerated: true,
3266                    marker: "3.".into(),
3267                }),
3268            },
3269        );
3270        let _region = t.add(
3271            Some(sub),
3272            None,
3273            TreeKind::FieldRegion {
3274                items: vec![crate::FieldItem {
3275                    marker: None,
3276                    key: Some("Name".into()),
3277                    value: Some("Duck".into()),
3278                    value_kind: Some("read_only".into()),
3279                }],
3280            },
3281        );
3282        let table = t.add(
3283            Some(sub),
3284            None,
3285            TreeKind::Table {
3286                table: Table {
3287                    rows: vec![vec!["a  \n&lt;".into(), "b".into()]],
3288                    cells: Some(vec![
3289                        crate::TableCell {
3290                            text: "a  \n&lt;".into(),
3291                            bbox: None,
3292                            start_row: 0,
3293                            start_col: 0,
3294                            row_span: 3,
3295                            col_span: 1,
3296                            column_header: false,
3297                            row_header: true,
3298                            row_section: false,
3299                        },
3300                        crate::TableCell {
3301                            text: "b".into(),
3302                            bbox: None,
3303                            start_row: 0,
3304                            start_col: 1,
3305                            row_span: 1,
3306                            col_span: 1,
3307                            column_header: false,
3308                            row_header: false,
3309                            row_section: false,
3310                        },
3311                    ]),
3312                    ..Table::default()
3313                },
3314                rich_cells: vec![(0, 1, 0)], // patched below
3315                captions: Vec::new(),
3316            },
3317        );
3318        let cell_group = t.add(
3319            Some(table),
3320            None,
3321            TreeKind::Group {
3322                label: "unspecified".into(),
3323                name: "rich_cell_group_1_0_0".into(),
3324            },
3325        );
3326        if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
3327            *rich_cells = vec![(0, 1, cell_group)];
3328        }
3329        let after = t.add(Some(sub), None, text("text", "after the region"));
3330        let _ = (title, after);
3331        // A list item belongs in a `list` group — one outside it would be
3332        // re-homed by `wrap_misplaced_list_items` (created last, so the
3333        // other groups keep their numbers).
3334        let list = t.add(
3335            Some(sub),
3336            None,
3337            TreeKind::Group {
3338                label: "list".into(),
3339                name: "list".into(),
3340            },
3341        );
3342        t.reparent(item, Some(list));
3343
3344        let doc = DoclingDocument {
3345            tree: Some(t),
3346            ..DoclingDocument::new("t")
3347        };
3348        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3349        // Creation order: Page, Heading, bold, x = 1, Sub, item, [Name, Duck], after.
3350        let texts: Vec<&str> = v["texts"]
3351            .as_array()
3352            .unwrap()
3353            .iter()
3354            .map(|t| t["text"].as_str().unwrap())
3355            .collect();
3356        assert_eq!(
3357            texts,
3358            [
3359                "Page",
3360                "Heading",
3361                "bold",
3362                "x = 1",
3363                "Sub",
3364                "item",
3365                "Name",
3366                "Duck",
3367                "after the region"
3368            ]
3369        );
3370        assert_eq!(
3371            v["body"]["children"],
3372            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}])
3373        );
3374        assert_eq!(v["texts"][0]["content_layer"], "furniture");
3375        assert_eq!(
3376            v["texts"][1]["children"],
3377            serde_json::json!([{"$ref": "#/groups/0"}, {"$ref": "#/texts/4"}])
3378        );
3379        let bold = &v["texts"][2];
3380        assert_eq!(bold["parent"]["$ref"], "#/groups/0");
3381        let keys: Vec<&str> = bold
3382            .as_object()
3383            .unwrap()
3384            .keys()
3385            .map(String::as_str)
3386            .collect();
3387        assert_eq!(
3388            keys,
3389            [
3390                "self_ref",
3391                "parent",
3392                "children",
3393                "content_layer",
3394                "label",
3395                "prov",
3396                "orig",
3397                "text",
3398                "formatting",
3399                "hyperlink"
3400            ]
3401        );
3402        assert_eq!(
3403            bold["formatting"],
3404            serde_json::json!({"bold": true, "italic": false, "underline": false, "strikethrough": false, "script": "baseline"})
3405        );
3406        let code = &v["texts"][3];
3407        assert_eq!(code["label"], "code");
3408        assert_eq!(code["code_language"], "Python");
3409        let sub = &v["texts"][4];
3410        assert_eq!(sub["orig"], "Sub\u{2019}");
3411        assert_eq!(sub["level"], 1);
3412        let item = &v["texts"][5];
3413        let keys: Vec<&str> = item
3414            .as_object()
3415            .unwrap()
3416            .keys()
3417            .map(String::as_str)
3418            .collect();
3419        assert_eq!(
3420            keys,
3421            [
3422                "self_ref",
3423                "parent",
3424                "children",
3425                "content_layer",
3426                "label",
3427                "prov",
3428                "orig",
3429                "text",
3430                "enumerated",
3431                "marker"
3432            ]
3433        );
3434        assert_eq!(item["marker"], "3.");
3435        assert_eq!(v["texts"][7]["kind"], "read_only");
3436        assert_eq!(v["field_regions"][0]["parent"]["$ref"], "#/texts/4");
3437        let table = &v["tables"][0];
3438        assert_eq!(
3439            table["children"],
3440            serde_json::json!([{"$ref": "#/groups/1"}])
3441        );
3442        let cells = table["data"]["table_cells"].as_array().unwrap();
3443        assert_eq!(
3444            cells[0]["text"], "a  \n&lt;",
3445            "raw cell text is written verbatim"
3446        );
3447        assert_eq!(
3448            cells[0]["end_row_offset_idx"], 3,
3449            "declared spans are not clamped"
3450        );
3451        assert_eq!(cells[1]["ref"], serde_json::json!({"$ref": "#/groups/1"}));
3452        assert!(cells[0].get("ref").is_none());
3453        assert!(
3454            table["data"]["grid"][0][1].get("ref").is_none(),
3455            "the grid shows plain cells"
3456        );
3457        assert_eq!(v["groups"][1]["name"], "rich_cell_group_1_0_0");
3458    }
3459
3460    /// A comment section that links its note text rather than its group — the
3461    /// spreadsheet shape, where docling-core's `add_comment` appends the text
3462    /// item's ref to each target.
3463    #[test]
3464    fn a_comment_section_can_be_referenced_by_its_note_text() {
3465        let doc = DoclingDocument {
3466            name: "c".into(),
3467            nodes: vec![
3468                Node::Commented {
3469                    comments: vec![0],
3470                    inner: Box::new(Node::Paragraph {
3471                        text: "annotated".into(),
3472                    }),
3473                },
3474                Node::CommentSection {
3475                    name: "comment-Sheet1-A1".into(),
3476                    text: "[author: A]: note".into(),
3477                    refs_note_text: true,
3478                    grouped: true,
3479                },
3480            ],
3481            ..DoclingDocument::new("c")
3482        };
3483        let v = crate::json::to_json(&doc);
3484        assert_eq!(v["groups"][0]["name"], "comment-Sheet1-A1");
3485        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/texts/1");
3486    }
3487
3488    /// docx reviewer comments: a `comment_section` group on the notes layer
3489    /// holding the note text, and a `comments` back-ref on the annotated item —
3490    /// keyed between `prov` and `orig`, the slot docling emits it in.
3491    #[test]
3492    fn comment_sections_link_back_to_their_items() {
3493        let doc = DoclingDocument {
3494            name: "c".into(),
3495            nodes: vec![
3496                Node::Commented {
3497                    comments: vec![0],
3498                    inner: Box::new(Node::Paragraph {
3499                        text: "annotated".into(),
3500                    }),
3501                },
3502                Node::Paragraph {
3503                    text: "plain".into(),
3504                },
3505                Node::CommentSection {
3506                    name: "comment-7".into(),
3507                    text: "[time: t]: note".into(),
3508                    refs_note_text: false,
3509                    grouped: true,
3510                },
3511            ],
3512            ..DoclingDocument::new("c")
3513        };
3514        let v = crate::json::to_json(&doc);
3515        // The group is the comment section; its only child is the notes text.
3516        assert_eq!(v["groups"][0]["label"], "comment_section");
3517        assert_eq!(v["groups"][0]["name"], "comment-7");
3518        assert_eq!(v["groups"][0]["content_layer"], "notes");
3519        assert_eq!(v["groups"][0]["children"][0]["$ref"], "#/texts/2");
3520        assert_eq!(v["texts"][2]["content_layer"], "notes");
3521        // The annotated item points back at the group; the plain one has no key.
3522        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/groups/0");
3523        assert!(v["texts"][1].get("comments").is_none());
3524        // docling's key order: … prov, comments, orig, text.
3525        let keys: Vec<&str> = v["texts"][0]
3526            .as_object()
3527            .unwrap()
3528            .keys()
3529            .map(String::as_str)
3530            .collect();
3531        assert_eq!(
3532            &keys[keys.len() - 4..],
3533            &["prov", "comments", "orig", "text"]
3534        );
3535    }
3536
3537    fn picture(caption: &str, caption_parent: CaptionParent) -> Node {
3538        Node::Picture {
3539            caption: Some(caption.into()),
3540            caption_href: None,
3541            image: None,
3542            classification: None,
3543            caption_parent,
3544            caption_location: None,
3545        }
3546    }
3547
3548    fn group(children: Vec<Node>) -> Node {
3549        Node::Group {
3550            label: "section".into(),
3551            name: None,
3552            layer: None,
3553            children,
3554        }
3555    }
3556
3557    fn refs(v: &Value) -> Vec<&str> {
3558        v.as_array()
3559            .unwrap()
3560            .iter()
3561            .map(|r| r["$ref"].as_str().unwrap())
3562            .collect()
3563    }
3564
3565    /// #390: a declarative backend's caption is docling's `add_text` default —
3566    /// a body child, appended as it is created — wherever the picture sits:
3567    /// ahead of a top-level picture, behind the top-level item enclosing a
3568    /// nested one. The picture references it either way and has no children.
3569    #[test]
3570    fn a_body_caption_follows_the_enclosing_top_level_item() {
3571        let mut doc = DoclingDocument::new("t");
3572        doc.push(picture("top", CaptionParent::Body));
3573        doc.push(group(vec![
3574            Node::Paragraph { text: "p".into() },
3575            picture("nested", CaptionParent::Body),
3576        ]));
3577        doc.push(Node::Paragraph {
3578            text: "after".into(),
3579        });
3580        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3581        assert_eq!(
3582            refs(&v["body"]["children"]),
3583            [
3584                "#/texts/0",
3585                "#/pictures/0",
3586                "#/groups/0",
3587                "#/texts/2",
3588                "#/texts/3"
3589            ]
3590        );
3591        assert_eq!(
3592            refs(&v["groups"][0]["children"]),
3593            ["#/texts/1", "#/pictures/1"]
3594        );
3595        for (cap, pic) in [(0, 0), (2, 1)] {
3596            assert_eq!(v["texts"][cap]["label"], "caption");
3597            assert_eq!(v["texts"][cap]["parent"]["$ref"], "#/body");
3598            assert_eq!(
3599                refs(&v["pictures"][pic]["captions"]),
3600                [format!("#/texts/{cap}")]
3601            );
3602            assert_eq!(v["pictures"][pic]["children"], serde_json::json!([]));
3603        }
3604    }
3605
3606    /// The PDF pipeline's caption is the picture's (or table's) own child,
3607    /// as docling attaches a layout caption.
3608    #[test]
3609    fn an_item_caption_is_the_items_first_child() {
3610        let mut doc = DoclingDocument::new("t");
3611        doc.push(picture("fig", CaptionParent::Item));
3612        doc.push(Node::Table(Table {
3613            rows: vec![vec!["a".into()]],
3614            caption: Some("tab".into()),
3615            caption_parent: CaptionParent::Item,
3616            ..Table::default()
3617        }));
3618        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3619        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/tables/0"]);
3620        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/pictures/0");
3621        assert_eq!(refs(&v["pictures"][0]["children"]), ["#/texts/0"]);
3622        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
3623        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/tables/0");
3624        assert_eq!(refs(&v["tables"][0]["children"]), ["#/texts/1"]);
3625        assert_eq!(refs(&v["tables"][0]["captions"]), ["#/texts/1"]);
3626    }
3627
3628    /// A PDF picture's contained text (`Node::PictureChildren`) is written as
3629    /// docling's `_add_child_elements` writes it: items parented to the
3630    /// picture, after its caption in the picture's `children`, never in the
3631    /// body; a list item opens its own list group under the picture. The
3632    /// Markdown and the chunker's refs are unaffected.
3633    #[test]
3634    fn picture_children_hang_off_the_picture_after_its_caption() {
3635        let mut doc = DoclingDocument::new("t");
3636        doc.push(picture("fig", CaptionParent::Item));
3637        doc.push(Node::PictureChildren(vec![
3638            Node::Heading {
3639                level: 2,
3640                text: "in-figure title".into(),
3641            },
3642            Node::Paragraph {
3643                text: "axis label".into(),
3644            },
3645            Node::ListItem {
3646                ordered: false,
3647                number: 0,
3648                first_in_list: true,
3649                text: "callout".into(),
3650                level: 0,
3651                marker: None,
3652                location: None,
3653                dclx: None,
3654                href: None,
3655                layer: None,
3656            },
3657        ]));
3658        doc.push(Node::Paragraph {
3659            text: "after".into(),
3660        });
3661        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3662        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/texts/4"]);
3663        assert_eq!(
3664            refs(&v["pictures"][0]["children"]),
3665            ["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"]
3666        );
3667        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
3668        assert_eq!(v["texts"][1]["label"], "section_header");
3669        assert_eq!(v["texts"][2]["label"], "text");
3670        for t in 1..=2 {
3671            assert_eq!(v["texts"][t]["parent"]["$ref"], "#/pictures/0");
3672            assert_eq!(v["texts"][t]["content_layer"], "body");
3673        }
3674        assert_eq!(v["groups"][0]["parent"]["$ref"], "#/pictures/0");
3675        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
3676        let md = doc.export_to_markdown();
3677        assert!(
3678            !md.contains("axis label") && !md.contains("callout"),
3679            "{md}"
3680        );
3681        assert!(md.contains("after"), "{md}");
3682    }
3683
3684    /// A container caption sits beside its item under the item's parent —
3685    /// ahead of it (an office chart's title) or behind it (an HTML
3686    /// `<figure>`'s table, whose figcaption docling adds after the table).
3687    #[test]
3688    fn a_container_caption_is_the_items_sibling() {
3689        let mut doc = DoclingDocument::new("t");
3690        doc.push(group(vec![
3691            picture("chart", CaptionParent::Container),
3692            Node::Table(Table {
3693                rows: vec![vec!["a".into()]],
3694                caption: Some("figcaption".into()),
3695                caption_parent: CaptionParent::ContainerAfter,
3696                ..Table::default()
3697            }),
3698        ]));
3699        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3700        assert_eq!(refs(&v["body"]["children"]), ["#/groups/0"]);
3701        assert_eq!(
3702            refs(&v["groups"][0]["children"]),
3703            ["#/texts/0", "#/pictures/0", "#/tables/0", "#/texts/1"]
3704        );
3705        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/groups/0");
3706        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/groups/0");
3707        assert_eq!(v["pictures"][0]["children"], serde_json::json!([]));
3708        assert_eq!(v["tables"][0]["children"], serde_json::json!([]));
3709    }
3710
3711    /// A PDF footnote (#620 follow-up): the JSON keeps docling's
3712    /// `footnote` label, the raw text and the link as `hyperlink`; the
3713    /// Markdown still wraps the whole item as `[text](uri)`, and the plain
3714    /// text drops the link like docling-core's `export_to_text`.
3715    #[test]
3716    fn a_labeled_text_keeps_its_label_and_hyperlink_out_of_the_text() {
3717        let mut doc = DoclingDocument::new("t");
3718        doc.push(Node::LabeledText {
3719            label: "footnote".into(),
3720            text: "1 https://example.com/a\\_b".into(),
3721            href: Some("https://example.com/a_b".into()),
3722        });
3723        doc.push(Node::LabeledText {
3724            label: "footnote".into(),
3725            text: "© 2022 the authors".into(),
3726            href: None,
3727        });
3728        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
3729        let t = &v["texts"][0];
3730        assert_eq!(t["label"], "footnote");
3731        assert_eq!(t["text"], "1 https://example.com/a_b");
3732        assert_eq!(t["orig"], "1 https://example.com/a_b");
3733        assert_eq!(t["hyperlink"], "https://example.com/a_b");
3734        assert_eq!(v["texts"][1]["label"], "footnote");
3735        assert!(v["texts"][1].get("hyperlink").is_none());
3736        assert_eq!(
3737            doc.export_to_markdown(),
3738            "[1 https://example.com/a\\_b](https://example.com/a_b)\n\n© 2022 the authors\n"
3739        );
3740        assert_eq!(
3741            doc.export_to_text(),
3742            "1 https://example.com/a_b\n\n© 2022 the authors"
3743        );
3744    }
3745}