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