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