Skip to main content

docling_core/
json.rs

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