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