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