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/// docling-core's `Formatting` model, every field written.
103fn formatting_json(f: &crate::tree::Formatting) -> Value {
104    json!({
105        "bold": f.bold,
106        "italic": f.italic,
107        "underline": f.underline,
108        "strikethrough": f.strikethrough,
109        "script": match f.script {
110            crate::Script::Baseline => "baseline",
111            crate::Script::Sub => "sub",
112            crate::Script::Super => "super",
113        },
114    })
115}
116
117/// Build the docling-core JSON object for `doc`.
118pub fn to_json(doc: &DoclingDocument) -> Value {
119    let mut b = Builder::default();
120    // A backend that built docling's item tree ([`crate::tree`]) has already
121    // decided every parent, child and creation index; serialize that. The
122    // flat nodes are for the other serializers.
123    let body = match &doc.tree {
124        Some(tree) => b.write_tree(tree),
125        None => b.walk_into(&doc.nodes, "#/body"),
126    };
127    b.link_comments();
128
129    let mut out = json!({
130        "schema_name": "DoclingDocument",
131        "version": SCHEMA_VERSION,
132        "name": doc.name,
133        "origin": {
134            "mimetype": "text/plain",
135            "binary_hash": fnv1a(&doc.name),
136            "filename": doc.name,
137        },
138        "furniture": {
139            "self_ref": "#/furniture",
140            "children": [],
141            "content_layer": "furniture",
142            "name": "_root_",
143            "label": "unspecified",
144        },
145        "body": {
146            "self_ref": "#/body",
147            "children": body,
148            "content_layer": "body",
149            "name": "_root_",
150            "label": "unspecified",
151        },
152        "groups": b.groups,
153        "texts": b.texts,
154        "pictures": b.pictures,
155        "tables": b.tables,
156        "key_value_items": [],
157        "form_items": [],
158        "pages": b.pages.iter().map(|(n, w, h)| {
159            let r2 = |v: f64| (v * 100.0).round() / 100.0;
160            (n.to_string(), json!({
161                "size": { "width": r2(*w), "height": r2(*h) },
162                "page_no": n,
163            }))
164        }).collect::<serde_json::Map<String, Value>>(),
165    });
166
167    // docling only emits `field_regions` / `field_items` when a document has
168    // form fields, and places them just before `pages`. Insert them in that slot
169    // (re-appending `pages` afterwards, since `preserve_order` keeps insertion
170    // order) so non-KVP documents' JSON is byte-identical to before.
171    if !b.field_regions.is_empty() {
172        if let Some(obj) = out.as_object_mut() {
173            let pages = obj.remove("pages");
174            obj.insert("field_regions".into(), Value::Array(b.field_regions));
175            obj.insert("field_items".into(), Value::Array(b.field_items));
176            if let Some(pages) = pages {
177                obj.insert("pages".into(), pages);
178            }
179        }
180    }
181    out
182}
183
184/// A DocumentPictureClassifier's predictions as the picture's `meta` — they
185/// land twice, exactly like docling 2.x writes them: the newer
186/// `meta.classification` field (pydantic field order: confidence, created_by,
187/// class_name) and the deprecated-but-still-emitted `classification`
188/// annotation, carried here under an `annotations` key that `add_picture`
189/// lifts onto the item.
190fn classification_meta(classes: &[crate::PictureClass]) -> Value {
191    json!({
192        "classification": {
193            "predictions": classes.iter().map(|c| json!({
194                "confidence": c.confidence as f64,
195                "created_by": "DocumentPictureClassifier",
196                "class_name": c.class_name,
197            })).collect::<Vec<_>>(),
198        },
199        "annotations": [{
200            "kind": "classification",
201            "provenance": "DocumentPictureClassifier",
202            "predicted_classes": classes.iter().map(|c| json!({
203                "class_name": c.class_name,
204                "confidence": c.confidence as f64,
205            })).collect::<Vec<_>>(),
206        }],
207    })
208}
209
210/// docling's `TableData` for a table: `table_cells`, `num_rows`/`num_cols`
211/// and the `grid` that repeats each cell at every position it covers. Shared
212/// by table items and a chart picture's `meta.tabular_chart.chart_data`.
213/// One table cell as docling's `TableCell` JSON: the eleven fields in
214/// pydantic order, `bbox` appended when the cell carries one. Built straight
215/// into a `Map` sized for the entries — the table grid repeats every cell
216/// object once per spanned slot, so on a table-heavy document (a patent's
217/// claims tables, an EBCDIC dump) this constructor and its clones *are* the
218/// JSON export's cost; the `json!` macro built the same object through a
219/// growing map with a rehash per doubling.
220#[allow(clippy::too_many_arguments)]
221fn cell_value(
222    row_span: usize,
223    col_span: usize,
224    start_row: usize,
225    end_row: usize,
226    start_col: usize,
227    end_col: usize,
228    text: String,
229    column_header: bool,
230    row_header: bool,
231    row_section: bool,
232    bbox: Option<[f32; 4]>,
233) -> Value {
234    let mut m = serde_json::Map::with_capacity(12);
235    m.insert("row_span".into(), row_span.into());
236    m.insert("col_span".into(), col_span.into());
237    m.insert("start_row_offset_idx".into(), start_row.into());
238    m.insert("end_row_offset_idx".into(), end_row.into());
239    m.insert("start_col_offset_idx".into(), start_col.into());
240    m.insert("end_col_offset_idx".into(), end_col.into());
241    m.insert("text".into(), Value::String(text));
242    m.insert("column_header".into(), column_header.into());
243    m.insert("row_header".into(), row_header.into());
244    m.insert("row_section".into(), row_section.into());
245    m.insert("fillable".into(), false.into());
246    if let Some(b) = bbox {
247        m.insert(
248            "bbox".into(),
249            json!({
250                "l": b[0], "t": b[1], "r": b[2], "b": b[3],
251                "coord_origin": "TOPLEFT",
252            }),
253        );
254    }
255    Value::Object(m)
256}
257
258/// `TableData` from a table whose cell text is Markdown-flavoured (the flat
259/// nodes: escapes to undo, GFM hard-break markers to strip).
260fn table_data(t: &Table) -> Value {
261    table_data_with(t, false)
262}
263
264/// `TableData`; with `raw` the cell text is docling's own raw cell text (a
265/// backend-built tree) and is written verbatim.
266fn table_data_with(t: &Table, raw: bool) -> Value {
267    let cell_text = |s: &str| {
268        if raw {
269            s.to_string()
270        } else {
271            unescape_text(&crate::markdown::strip_hard_breaks(s))
272        }
273    };
274    let num_rows = t.rows.len();
275    let num_cols = t.rows.iter().map(Vec::len).max().unwrap_or(0);
276    let mut grid = Vec::with_capacity(num_rows);
277    let mut cells = Vec::new();
278    // Grid slot → index into `cells` (the anchor cell covering it). A flat
279    // row-major table instead of a `HashMap<(r, c), Value>` of clones: the
280    // grid is filled from it with one clone per slot, and nothing is hashed.
281    let mut slot: Vec<Option<usize>> = vec![None; num_rows * num_cols];
282    if let Some(first_class) = t.cells.as_ref().filter(|c| !c.is_empty()) {
283        for c in first_class {
284            let idx = cells.len();
285            cells.push(cell_value(
286                c.row_span,
287                c.col_span,
288                c.start_row,
289                c.start_row + c.row_span,
290                c.start_col,
291                c.start_col + c.col_span,
292                cell_text(&c.text),
293                c.column_header,
294                c.row_header,
295                c.row_section,
296                c.bbox,
297            ));
298            for r in c.start_row..(c.start_row + c.row_span).min(num_rows) {
299                for k in c.start_col..(c.start_col + c.col_span).min(num_cols) {
300                    slot[r * num_cols + k] = Some(idx);
301                }
302            }
303        }
304        for r in 0..num_rows {
305            let mut grid_row = Vec::with_capacity(num_cols);
306            for c in 0..num_cols {
307                grid_row.push(match slot[r * num_cols + c] {
308                    Some(i) => cells[i].clone(),
309                    None => cell_value(
310                        1,
311                        1,
312                        r,
313                        r + 1,
314                        c,
315                        c + 1,
316                        String::new(),
317                        false,
318                        false,
319                        false,
320                        None,
321                    ),
322                });
323            }
324            grid.push(grid_row);
325        }
326    } else {
327        let s = t.structure.as_ref();
328        let flag = |grid: Option<&Vec<Vec<bool>>>, r: usize, c: usize| -> bool {
329            grid.and_then(|g| g.get(r))
330                .and_then(|row| row.get(c))
331                .copied()
332                .unwrap_or(false)
333        };
334        let anchor_of = |r: usize, c: usize| -> (usize, usize) {
335            let (mut r0, mut c0) = (r, c);
336            while c0 > 0 && flag(s.map(|s| &s.col_continuation), r, c0) {
337                c0 -= 1;
338            }
339            while r0 > 0 && flag(s.map(|s| &s.row_continuation), r0, c0) {
340                r0 -= 1;
341            }
342            (r0, c0)
343        };
344        // Each slot's anchor, computed once; the anchor's extent is the
345        // farthest slot that resolves to it.
346        let anchors: Vec<(usize, usize)> = (0..num_rows)
347            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
348            .map(|(r, c)| anchor_of(r, c))
349            .collect();
350        let mut extent: Vec<(usize, usize)> = (0..num_rows)
351            .flat_map(|r| (0..num_cols).map(move |c| (r, c)))
352            .collect();
353        for (i, &(ar, ac)) in anchors.iter().enumerate() {
354            let (r, c) = (i / num_cols.max(1), i % num_cols.max(1));
355            let e = &mut extent[ar * num_cols + ac];
356            e.0 = e.0.max(r);
357            e.1 = e.1.max(c);
358        }
359        for (r, row) in t.rows.iter().enumerate() {
360            let mut grid_row = Vec::with_capacity(num_cols);
361            for c in 0..num_cols {
362                let (ar, ac) = anchors[r * num_cols + c];
363                if (ar, ac) == (r, c) {
364                    let (er, ec) = extent[r * num_cols + c];
365                    let text = row.get(c).map(|s| cell_text(s)).unwrap_or_default();
366                    let column_header = match s.filter(|s| !s.col_header.is_empty()) {
367                        Some(s) => flag(Some(&s.col_header), r, c),
368                        None => r == 0,
369                    };
370                    slot[r * num_cols + c] = Some(cells.len());
371                    cells.push(cell_value(
372                        er - r + 1,
373                        ec - c + 1,
374                        r,
375                        er + 1,
376                        c,
377                        ec + 1,
378                        text,
379                        column_header,
380                        flag(s.map(|s| &s.row_header), r, c),
381                        false,
382                        None,
383                    ));
384                }
385                grid_row.push(match slot[ar * num_cols + ac] {
386                    Some(i) => cells[i].clone(),
387                    None => Value::Null,
388                });
389            }
390            grid.push(grid_row);
391        }
392    }
393    json!({
394        "table_cells": cells,
395        "num_rows": num_rows,
396        "num_cols": num_cols,
397        "orientation": "rot_0",
398        "grid": grid,
399    })
400}
401
402#[derive(Default)]
403struct Builder {
404    texts: Vec<Value>,
405    groups: Vec<Value>,
406    tables: Vec<Value>,
407    pictures: Vec<Value>,
408    field_regions: Vec<Value>,
409    field_items: Vec<Value>,
410    /// Pages seen so far (`page_no`, width, height in points) — from the
411    /// [`Node::PageInfo`] markers the PDF paths emit; empty for every other
412    /// backend, which keeps their JSON byte-identical (`"pages": {}`, no prov).
413    pages: Vec<(usize, f64, f64)>,
414    /// The page the walk is currently on (0 before the first marker).
415    cur_page: usize,
416    cur_w: f64,
417    cur_h: f64,
418    /// The enclosing [`Node::Located`] wrapper's 0–511 grid box, waiting to be
419    /// consumed as the next item's provenance.
420    pending_loc: Option<[u16; 4]>,
421    /// The enclosing [`Node::Prov`] wrapper's exact `(page_no, bbox,
422    /// charspan)`, which takes precedence over the grid box.
423    pending_exact: Option<(usize, [f32; 4], [usize; 2])>,
424    /// `$ref`s an item wants placed in its parent's `children` *before* its
425    /// own — a chart's caption item, which docling's office backends add to
426    /// the container ahead of the picture that references it.
427    pending_siblings: Vec<Value>,
428    /// `$ref`s an item wants placed in its parent's `children` right *after*
429    /// its own — an HTML `<figure>`-wrapped table's caption
430    /// ([`CaptionParent::ContainerAfter`]).
431    pending_after: Vec<Value>,
432    /// Caption `$ref`s that hang off `#/body` while their item sits deeper
433    /// ([`CaptionParent::Body`]): docling appends them to the body's children
434    /// as they are created, so they follow the top-level item being walked.
435    pending_body: Vec<Value>,
436    /// What each [`Node::CommentSection`] is referenced by, in document order —
437    /// its group `$ref`, or its note text's when the section says so. The index
438    /// is what a [`Node::Commented`] annotation carries.
439    comment_groups: Vec<String>,
440    /// Annotated items awaiting their refs: comments are usually emitted
441    /// *after* the body they annotate (docx appends them), so the link is
442    /// patched in once the whole document has been walked.
443    pending_comments: Vec<(String, Vec<usize>)>,
444}
445
446impl Builder {
447    /// Consume the pending location (if any) into a docling `prov` array: the
448    /// 0-511 grid denormalized against the current page into BOTTOMLEFT
449    /// points, rounded to 2 decimals like docling's own export. `char_len` is
450    /// the item's text length in characters (0 for tables and pictures, whose
451    /// charspan docling emits as `[0, 0]`).
452    fn take_prov(&mut self, char_len: usize) -> Value {
453        let prov = self.prov_json(char_len, false);
454        self.pending_exact = None;
455        self.pending_loc = None;
456        prov
457    }
458
459    /// The pending provenance without consuming it. An exact [`Node::Prov`]
460    /// box wins over the grid; its own `charspan` is used unless
461    /// `span_over_text` asks for `[0, char_len]` (a chart caption's span
462    /// covers the caption text where the chart's is `[0, 0]`).
463    fn prov_json(&self, char_len: usize, span_over_text: bool) -> Value {
464        let r2 = |v: f64| (v * 100.0).round() / 100.0;
465        if let Some((page_no, [l, t, r, b], charspan)) = self.pending_exact {
466            let charspan = if span_over_text {
467                [0, char_len]
468            } else {
469                charspan
470            };
471            return json!([{
472                "page_no": page_no,
473                "bbox": {
474                    "l": r2(l as f64), "t": r2(t as f64), "r": r2(r as f64), "b": r2(b as f64),
475                    "coord_origin": "TOPLEFT",
476                },
477                "charspan": charspan,
478            }]);
479        }
480        let Some([x0, y0, x1, y1]) = self.pending_loc else {
481            return json!([]);
482        };
483        // An all-zero grid box is the sentinel for "this item has no geometry"
484        // (a slide's speaker notes, say). docling writes a zero bbox for it,
485        // not a box spanning the page, which is what denormalizing would give.
486        if [x0, y0, x1, y1] == [0, 0, 0, 0] {
487            return json!([{
488                "page_no": self.cur_page,
489                "bbox": { "l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT" },
490                "charspan": [0, char_len],
491            }]);
492        }
493        json!([{
494            "page_no": self.cur_page,
495            "bbox": {
496                "l": r2(x0 as f64 * self.cur_w / 512.0),
497                "t": r2(self.cur_h - y0 as f64 * self.cur_h / 512.0),
498                "r": r2(x1 as f64 * self.cur_w / 512.0),
499                "b": r2(self.cur_h - y1 as f64 * self.cur_h / 512.0),
500                "coord_origin": "BOTTOMLEFT",
501            },
502            "charspan": [0, char_len],
503        }])
504    }
505
506    /// Adopt a node's own location field (tables, formulas, list items carry
507    /// one instead of a [`Node::Located`] wrapper) when no wrapper is pending.
508    fn adopt_loc(&mut self, loc: Option<[u16; 4]>) {
509        if self.pending_loc.is_none() && self.cur_page > 0 {
510            self.pending_loc = loc;
511        }
512    }
513
514    /// Resolve the [`Node::Commented`] annotations collected during the walk
515    /// into docling's `comments: [{"$ref": "#/groups/N"}]` key on the annotated
516    /// item. It has to run after the whole walk: docx appends its comment
517    /// bodies, so the groups they live in are usually allocated *later* than
518    /// the paragraphs pointing at them. docling emits the key between `prov`
519    /// and `orig`, so insert it in place rather than appending (serde_json runs
520    /// with `preserve_order`, i.e. key order is output order).
521    fn link_comments(&mut self) {
522        let refs: Vec<(String, Vec<Value>)> = std::mem::take(&mut self.pending_comments)
523            .into_iter()
524            .map(|(item, comments)| {
525                let refs = comments
526                    .iter()
527                    .filter_map(|i| self.comment_groups.get(*i))
528                    .map(|r| json!({ "$ref": r }))
529                    .collect();
530                (item, refs)
531            })
532            .collect();
533        for (item, comment_refs) in refs {
534            if comment_refs.is_empty() {
535                continue;
536            }
537            let Some(target) = self.item_mut(&item) else {
538                continue;
539            };
540            let Some(obj) = target.as_object_mut() else {
541                continue;
542            };
543            let tail: Vec<(String, Value)> = obj
544                .iter()
545                .skip_while(|(k, _)| k.as_str() != "prov")
546                .skip(1)
547                .map(|(k, v)| (k.clone(), v.clone()))
548                .collect();
549            for (k, _) in &tail {
550                obj.shift_remove(k);
551            }
552            obj.insert("comments".into(), Value::Array(comment_refs));
553            for (k, v) in tail {
554                obj.insert(k, v);
555            }
556        }
557    }
558
559    /// The stored JSON object a `#/texts/N`-style self-ref points at.
560    fn item_mut(&mut self, self_ref: &str) -> Option<&mut Value> {
561        let idx = ref_index(self_ref)?;
562        let bucket = if self_ref.starts_with("#/texts/") {
563            &mut self.texts
564        } else if self_ref.starts_with("#/tables/") {
565            &mut self.tables
566        } else if self_ref.starts_with("#/pictures/") {
567            &mut self.pictures
568        } else if self_ref.starts_with("#/groups/") {
569            &mut self.groups
570        } else {
571            return None;
572        };
573        bucket.get_mut(idx)
574    }
575
576    /// Serialize a backend-built [`ItemTree`](crate::tree::ItemTree): every
577    /// item in creation order into its bucket, with the parent / children /
578    /// layer the tree recorded. Returns the body's `children` refs.
579    fn write_tree(&mut self, tree: &crate::tree::ItemTree) -> Vec<Value> {
580        use crate::tree::TreeKind;
581        // Every item's `self_ref` first: children may be listed before they
582        // are written (a rich cell's group is created after its content).
583        let mut refs: Vec<String> = Vec::with_capacity(tree.items.len());
584        let (mut nt, mut ng, mut ntb, mut np, mut nf) = (0, 0, 0, 0, 0);
585        for item in &tree.items {
586            let r = match &item.kind {
587                TreeKind::Text { .. } | TreeKind::Code { .. } => {
588                    nt += 1;
589                    format!("#/texts/{}", nt - 1)
590                }
591                TreeKind::Group { .. } => {
592                    ng += 1;
593                    format!("#/groups/{}", ng - 1)
594                }
595                TreeKind::Table { .. } => {
596                    ntb += 1;
597                    format!("#/tables/{}", ntb - 1)
598                }
599                TreeKind::Picture { .. } => {
600                    np += 1;
601                    format!("#/pictures/{}", np - 1)
602                }
603                TreeKind::FieldRegion { items } => {
604                    // A region's marker / key / value parts are text items
605                    // too, numbered where docling creates them.
606                    nt += items
607                        .iter()
608                        .map(|i| {
609                            [&i.marker, &i.key, &i.value]
610                                .iter()
611                                .filter(|p| p.is_some())
612                                .count()
613                        })
614                        .sum::<usize>();
615                    nf += 1;
616                    format!("#/field_regions/{}", nf - 1)
617                }
618            };
619            refs.push(r);
620        }
621        let ref_of = |id: usize| json!({ "$ref": refs[id] });
622        for (id, item) in tree.items.iter().enumerate() {
623            let parent = item.parent.map_or("#/body", |p| refs[p].as_str());
624            let children: Vec<Value> = item.children.iter().map(|&c| ref_of(c)).collect();
625            let layer = item.layer.map_or("body", |l| l.value());
626            let self_ref = match &item.kind {
627                TreeKind::Text {
628                    label,
629                    text,
630                    orig,
631                    formatting,
632                    hyperlink,
633                    level,
634                    list,
635                } => {
636                    // docling's field order: …, text, formatting, hyperlink,
637                    // then the subclass fields (`level`; `enumerated`, `marker`).
638                    let mut tail = serde_json::Map::new();
639                    if let Some(f) = formatting {
640                        tail.insert("formatting".into(), formatting_json(f));
641                    }
642                    if let Some(h) = hyperlink {
643                        tail.insert("hyperlink".into(), json!(h));
644                    }
645                    if let Some(l) = level {
646                        tail.insert("level".into(), json!(l));
647                    }
648                    if let Some(l) = list {
649                        tail.insert("enumerated".into(), json!(l.enumerated));
650                        tail.insert("marker".into(), json!(l.marker));
651                    }
652                    let r = format!("#/texts/{}", self.texts.len());
653                    let mut item_json = json!({
654                        "self_ref": r,
655                        "parent": { "$ref": parent },
656                        "children": children,
657                        "content_layer": layer,
658                        "label": label,
659                        "prov": [],
660                        "orig": orig.as_deref().unwrap_or(text),
661                        "text": text,
662                    });
663                    merge(&mut item_json, Value::Object(tail));
664                    self.texts.push(item_json);
665                    r
666                }
667                TreeKind::Code {
668                    text,
669                    orig,
670                    language,
671                    formatting,
672                    hyperlink,
673                } => {
674                    let r = format!("#/texts/{}", self.texts.len());
675                    let mut item_json = json!({
676                        "self_ref": r,
677                        "parent": { "$ref": parent },
678                        "children": children,
679                        "content_layer": layer,
680                        "label": "code",
681                        "prov": [],
682                        "orig": orig.as_deref().unwrap_or(text),
683                        "text": text,
684                    });
685                    if let Some(f) = formatting {
686                        item_json["formatting"] = formatting_json(f);
687                    }
688                    if let Some(h) = hyperlink {
689                        item_json["hyperlink"] = json!(h);
690                    }
691                    merge(
692                        &mut item_json,
693                        json!({
694                            "captions": [],
695                            "references": [],
696                            "footnotes": [],
697                            "code_language": code_language(language.as_deref()),
698                        }),
699                    );
700                    self.texts.push(item_json);
701                    r
702                }
703                TreeKind::Group { label, name } => {
704                    let r = format!("#/groups/{}", self.groups.len());
705                    self.groups.push(json!({
706                        "self_ref": r,
707                        "parent": { "$ref": parent },
708                        "children": children,
709                        "content_layer": layer,
710                        "name": name,
711                        "label": label,
712                    }));
713                    r
714                }
715                TreeKind::Table {
716                    table,
717                    rich_cells,
718                    captions,
719                } => {
720                    // docling's raw cell text: nothing to unescape or strip.
721                    let r = self.add_table_with(table, parent, true);
722                    let idx = ref_index(&r).expect("table ref");
723                    let t = &mut self.tables[idx];
724                    t["children"] = Value::Array(children);
725                    t["content_layer"] = json!(layer);
726                    t["captions"] = Value::Array(captions.iter().map(|&c| ref_of(c)).collect());
727                    // A `RichTableCell` is the plain cell plus a `ref` to the
728                    // group holding its content — on `table_cells` only; the
729                    // derived `grid` shows plain cells.
730                    for &(row, col, group) in rich_cells {
731                        let cell_ref = ref_of(group);
732                        let hit = |c: &Value| {
733                            c["start_row_offset_idx"] == json!(row)
734                                && c["start_col_offset_idx"] == json!(col)
735                        };
736                        if let Some(cells) = t["data"]["table_cells"].as_array_mut() {
737                            for c in cells.iter_mut().filter(|c| hit(c)) {
738                                c["ref"] = cell_ref.clone();
739                            }
740                        }
741                    }
742                    r
743                }
744                TreeKind::Picture {
745                    captions,
746                    image,
747                    classification,
748                } => {
749                    let meta = classification.as_ref().map(
750                        |c| json!({ "classification": { "predictions": [{ "class_name": c }] } }),
751                    );
752                    let r = self.push_picture(
753                        json!([]),
754                        captions.iter().map(|&c| ref_of(c)).collect(),
755                        children,
756                        image.as_ref(),
757                        meta,
758                        parent,
759                    );
760                    if let Some(idx) = ref_index(&r) {
761                        self.pictures[idx]["content_layer"] = json!(layer);
762                    }
763                    r
764                }
765                TreeKind::FieldRegion { items } => {
766                    let r = self.add_field_region(items, parent);
767                    if let Some(region) = self.field_regions.last_mut() {
768                        region["content_layer"] = json!(layer);
769                    }
770                    r
771                }
772            };
773            debug_assert_eq!(self_ref, refs[id], "tree item {id} numbered out of order");
774        }
775        tree.body.iter().map(|&c| ref_of(c)).collect()
776    }
777
778    fn add_node(&mut self, node: &Node, parent: &str) -> Option<String> {
779        match node {
780            Node::Heading { level: 1, text } => {
781                Some(self.add_text("title", text, parent, json!({})))
782            }
783            Node::Heading { level, text } => Some(self.add_text(
784                "section_header",
785                text,
786                parent,
787                json!({ "level": level.saturating_sub(1) }),
788            )),
789            Node::Caption { text, href } => {
790                let extra = match href {
791                    Some(url) => json!({ "hyperlink": url }),
792                    None => json!({}),
793                };
794                Some(self.add_text("caption", text, parent, extra))
795            }
796            Node::Paragraph { text } => {
797                // A whole-paragraph display equation is a formula item (docling
798                // wraps it in `$$…$$` and, unlike a text item, never escapes it).
799                let t = text.trim();
800                match t.strip_prefix("$$").and_then(|s| s.strip_suffix("$$")) {
801                    Some(inner) if !inner.is_empty() => Some(self.add_formula(inner, parent)),
802                    _ => Some(self.add_text("text", text, parent, json!({}))),
803                }
804            }
805            Node::CheckboxItem { checked, text } => {
806                // JSON keeps the task-list form as a plain text item (the
807                // `checkbox_selected`/`checkbox_unselected` label is DocLang-only).
808                let mark = if *checked { "- [x] " } else { "- [ ] " };
809                Some(self.add_text("text", &format!("{mark}{text}"), parent, json!({})))
810            }
811            Node::Code {
812                language,
813                text,
814                orig,
815                ..
816            } => Some(self.add_code(text, language.as_deref(), orig.as_deref(), parent)),
817            // A CodeFormula-decoded display formula: `text` carries the LaTeX,
818            // `orig` the raw glyph extraction (docling's enriched shape).
819            Node::Formula {
820                latex,
821                orig,
822                location,
823            } => {
824                self.adopt_loc(*location);
825                Some(self.add_formula_item(latex, orig, parent))
826            }
827            // docling's notes-layer `comment_section` group holding the
828            // comment's text item. What the annotated items point at differs
829            // upstream: the docx backend links the group (so a comment's
830            // replies group together), everything going through
831            // docling-core's `add_comment` links the note text itself.
832            Node::CommentSection {
833                name,
834                text,
835                refs_note_text,
836                grouped,
837            } => {
838                if !*grouped {
839                    // docling-core's bare `add_comment`: the note text sits
840                    // directly under the parent and is what the back-refs
841                    // point at.
842                    let child =
843                        self.add_text("text", text, parent, json!({ "content_layer": "notes" }));
844                    self.comment_groups.push(child.clone());
845                    return Some(child);
846                }
847                let self_ref = format!("#/groups/{}", self.groups.len());
848                self.groups.push(Value::Null);
849                let child =
850                    self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
851                self.groups[group_index(&self_ref)] = json!({
852                    "self_ref": self_ref,
853                    "parent": { "$ref": parent },
854                    "children": [{ "$ref": child }],
855                    "content_layer": "notes",
856                    "name": name,
857                    "label": "comment_section",
858                });
859                self.comment_groups.push(if *refs_note_text {
860                    child
861                } else {
862                    self_ref.clone()
863                });
864                Some(self_ref)
865            }
866            // The annotation itself is a cross-reference: emit the item, then
867            // remember it so the group refs can be filled in at the end.
868            Node::Commented { comments, inner } => {
869                let item = self.add_node(inner, parent)?;
870                if !comments.is_empty() {
871                    self.pending_comments.push((item.clone(), comments.clone()));
872                }
873                Some(item)
874            }
875            Node::Table(t) => Some(self.add_table(t, parent)),
876            Node::Picture {
877                caption,
878                caption_href,
879                image,
880                classification,
881                caption_parent,
882            } => Some(self.add_picture(
883                caption.as_deref(),
884                caption_href.as_deref(),
885                image.as_ref(),
886                classification.as_deref().map(classification_meta),
887                parent,
888                *caption_parent,
889            )),
890            // A chart is a picture item in the JSON with docling's chart
891            // meta — `classification` (the chart kind, as the one prediction)
892            // and `tabular_chart.chart_data`, the series reconstructed as a
893            // `TableData` (#405) — and no image payload.
894            Node::Chart {
895                kind,
896                table,
897                caption,
898                location,
899            } => {
900                self.adopt_loc(*location);
901                let mut meta = json!({
902                    "classification": { "predictions": [{ "class_name": kind }] },
903                });
904                if !table.rows.is_empty() {
905                    meta["tabular_chart"] = json!({ "chart_data": table_data(table) });
906                }
907                // docling's office backends add the chart's title as a caption
908                // item of the *container* (the sheet group, the slide), listed
909                // before the picture that references it, with the chart's own
910                // box and a charspan over the caption text — not as a child
911                // of the picture, which is where a PDF caption lives.
912                let mut captions = Vec::new();
913                if let Some(cap) = caption.as_deref().filter(|c| !c.is_empty()) {
914                    let prov = self.prov_json(unescape_text(cap).chars().count(), true);
915                    let cap_ref = self.add_text_with("caption", cap, parent, json!({}), prov);
916                    self.pending_siblings.push(json!({ "$ref": cap_ref }));
917                    captions.push(json!({ "$ref": cap_ref }));
918                }
919                let prov = self.take_prov(0);
920                Some(self.push_picture(prov, captions, Vec::new(), None, Some(meta), parent))
921            }
922            // A DocLang-only node is omitted from the JSON body.
923            Node::DoclangOnly(_) => None,
924            Node::Group {
925                label,
926                name,
927                layer,
928                children,
929            } => Some(self.add_group(label, name.as_deref(), *layer, children, parent)),
930            Node::FieldRegion { items } => Some(self.add_field_region(items, parent)),
931            // A rich inline group is a text item over its Markdown text; the
932            // structured runs are DocLang-only, so the JSON matches a paragraph.
933            Node::InlineGroup { md_text, .. } => {
934                Some(self.add_text("text", md_text, parent, json!({})))
935            }
936            // A plain-text backend dump is a single text item over the file body.
937            Node::TextDump(text) => Some(self.add_text("text", text, parent, json!({}))),
938            // Speaker notes are content a deck carries, and docling puts them
939            // in the JSON on their own layer, so a consumer reading only JSON
940            // can pick them (#402). Page furniture stays out of the flat
941            // path; a backend that builds docling's item tree (HTML) puts its
942            // furniture-layer items in the JSON through `write_tree`.
943            Node::Furniture {
944                layer: ContentLayer::Notes,
945                inner,
946            } => {
947                let item = self.add_node(inner, parent)?;
948                self.set_layer(&item, "notes");
949                Some(item)
950            }
951            Node::Furniture { .. } => None,
952            Node::PageFurniture { .. } => None,
953            // A location wrapper turns into the wrapped item's `prov` entry —
954            // but only on pages the PDF paths described with a PageInfo marker
955            // (other geometry-bearing backends, e.g. PPTX shapes, keep their
956            // pre-#171 provenance-less JSON until they emit markers too).
957            Node::Located { location, inner } => {
958                if self.cur_page > 0 {
959                    self.pending_loc = Some(*location);
960                }
961                let r = self.add_node(inner, parent);
962                self.pending_loc = None;
963                r
964            }
965            Node::Prov {
966                page_no,
967                bbox,
968                charspan,
969                inner,
970                ..
971            } => {
972                self.pending_exact = Some((*page_no, *bbox, *charspan));
973                let r = self.add_node(inner, parent);
974                self.pending_exact = None;
975                r
976            }
977            // Page breaks are DocLang-only; docling omits them from the JSON body.
978            Node::PageBreak => None,
979            // The page marker: record the page's number and size for the
980            // `pages` map, and denormalize every following location against it.
981            Node::PageInfo {
982                page_no,
983                width,
984                height,
985            } => {
986                self.cur_page = *page_no;
987                self.cur_w = *width as f64;
988                self.cur_h = *height as f64;
989                if *page_no > 0 {
990                    self.pages.push((*page_no, self.cur_w, self.cur_h));
991                }
992                None
993            }
994            // Handled by `add_list` in `walk`.
995            Node::ListItem { .. } => None,
996        }
997    }
998
999    /// A form key-value region: `field_regions/N` holds the region, each field is
1000    /// a `field_items/M` whose children are its `marker` / `field_key` /
1001    /// `field_value` texts (absent parts are simply omitted).
1002    fn add_field_region(&mut self, items: &[crate::FieldItem], parent: &str) -> String {
1003        let self_ref = format!("#/field_regions/{}", self.field_regions.len());
1004        self.field_regions.push(Value::Null);
1005        let region_index = self.field_regions.len() - 1;
1006        let mut item_refs = Vec::new();
1007        for item in items {
1008            item_refs.push(json!({ "$ref": self.add_field_item(item, &self_ref) }));
1009        }
1010        self.field_regions[region_index] = json!({
1011            "self_ref": self_ref,
1012            "parent": { "$ref": parent },
1013            "children": item_refs,
1014            "content_layer": "body",
1015            "label": "field_region",
1016            "prov": [],
1017        });
1018        self_ref
1019    }
1020
1021    fn add_field_item(&mut self, item: &crate::FieldItem, parent: &str) -> String {
1022        let self_ref = format!("#/field_items/{}", self.field_items.len());
1023        self.field_items.push(Value::Null);
1024        let item_index = self.field_items.len() - 1;
1025        let mut child_refs = Vec::new();
1026        for (label, text) in [
1027            ("marker", &item.marker),
1028            ("field_key", &item.key),
1029            ("field_value", &item.value),
1030        ] {
1031            if let Some(text) = text {
1032                // A value's `kind` (docling's `read_only` / `fillable`)
1033                // follows its text.
1034                let extra = match (label, &item.value_kind) {
1035                    ("field_value", Some(kind)) => json!({ "kind": kind }),
1036                    _ => json!({}),
1037                };
1038                child_refs.push(json!({ "$ref": self.add_text(label, text, &self_ref, extra) }));
1039            }
1040        }
1041        self.field_items[item_index] = json!({
1042            "self_ref": self_ref,
1043            "parent": { "$ref": parent },
1044            "children": child_refs,
1045            "content_layer": "body",
1046            "label": "field_item",
1047            "prov": [],
1048        });
1049        self_ref
1050    }
1051
1052    /// Move an already-emitted item onto a content layer. Notes are single
1053    /// text items today; a deeper notes subtree would need its children moved
1054    /// too, and no backend builds one.
1055    fn set_layer(&mut self, self_ref: &str, layer: &str) {
1056        let bucket = match self_ref.split('/').nth(1) {
1057            Some("texts") => &mut self.texts,
1058            Some("tables") => &mut self.tables,
1059            Some("pictures") => &mut self.pictures,
1060            Some("groups") => &mut self.groups,
1061            _ => return,
1062        };
1063        if let Some(item) = self_ref
1064            .rsplit('/')
1065            .next()
1066            .and_then(|i| i.parse::<usize>().ok())
1067            .and_then(|i| bucket.get_mut(i))
1068        {
1069            item["content_layer"] = json!(layer);
1070        }
1071    }
1072
1073    fn add_text(&mut self, label: &str, text: &str, parent: &str, extra: Value) -> String {
1074        let prov = self.take_prov(unescape_text(text).chars().count());
1075        self.add_text_with(label, text, parent, extra, prov)
1076    }
1077
1078    /// [`Self::add_text`] with an explicit `prov` (a chart caption shares the
1079    /// chart's box without consuming it).
1080    fn add_text_with(
1081        &mut self,
1082        label: &str,
1083        text: &str,
1084        parent: &str,
1085        extra: Value,
1086        prov: Value,
1087    ) -> String {
1088        let self_ref = format!("#/texts/{}", self.texts.len());
1089        let raw = unescape_text(text);
1090        let mut item = json!({
1091            "self_ref": self_ref,
1092            "parent": { "$ref": parent },
1093            "children": [],
1094            "content_layer": "body",
1095            "label": label,
1096            "prov": prov,
1097            "orig": raw,
1098            "text": raw,
1099        });
1100        merge(&mut item, extra);
1101        self.texts.push(item);
1102        self_ref
1103    }
1104
1105    /// A display-math formula item. `latex` is the raw content (no `$$`); docling
1106    /// re-wraps it and never escapes it.
1107    fn add_formula(&mut self, latex: &str, parent: &str) -> String {
1108        let self_ref = format!("#/texts/{}", self.texts.len());
1109        let prov = self.take_prov(latex.chars().count());
1110        self.texts.push(json!({
1111            "self_ref": self_ref,
1112            "parent": { "$ref": parent },
1113            "children": [],
1114            "content_layer": "body",
1115            "label": "formula",
1116            "prov": prov,
1117            "orig": latex,
1118            "text": latex,
1119        }));
1120        self_ref
1121    }
1122
1123    /// A CodeFormula-enriched display formula: `text` is the model's LaTeX
1124    /// while `orig` keeps the raw glyph extraction (docling's enriched shape;
1125    /// the plain [`Self::add_formula`] above sets both to the same string).
1126    fn add_formula_item(&mut self, latex: &str, orig: &str, parent: &str) -> String {
1127        let self_ref = format!("#/texts/{}", self.texts.len());
1128        let prov = self.take_prov(latex.chars().count());
1129        self.texts.push(json!({
1130            "self_ref": self_ref,
1131            "parent": { "$ref": parent },
1132            "children": [],
1133            "content_layer": "body",
1134            "label": "formula",
1135            "prov": prov,
1136            "orig": orig,
1137            "text": latex,
1138        }));
1139        self_ref
1140    }
1141
1142    fn add_code(
1143        &mut self,
1144        text: &str,
1145        language: Option<&str>,
1146        orig: Option<&str>,
1147        parent: &str,
1148    ) -> String {
1149        let self_ref = format!("#/texts/{}", self.texts.len());
1150        let raw = unescape_text(text);
1151        let prov = self.take_prov(raw.chars().count());
1152        self.texts.push(json!({
1153            "self_ref": self_ref,
1154            "parent": { "$ref": parent },
1155            "children": [],
1156            "content_layer": "body",
1157            "label": "code",
1158            "prov": prov,
1159            // With code enrichment, `text` is the model's rewrite while `orig`
1160            // keeps the raw extraction; otherwise both are the same string.
1161            "orig": orig.map(unescape_text).unwrap_or_else(|| raw.clone()),
1162            "text": raw,
1163            "captions": [],
1164            "references": [],
1165            "footnotes": [],
1166            "code_language": code_language(language),
1167        }));
1168        self_ref
1169    }
1170
1171    /// Build a list group from a run of (possibly multi-level) list items. A
1172    /// deeper level starts a nested list under the preceding item.
1173    fn add_list(&mut self, items: &[Node], parent: &str) -> String {
1174        let self_ref = format!("#/groups/{}", self.groups.len());
1175        // reserve the slot so nested groups get later indices
1176        self.groups.push(Value::Null);
1177        let base = level_of(&items[0]);
1178        let mut children = Vec::new();
1179        let mut i = 0;
1180        while i < items.len() {
1181            // Empty paragraphs absorbed into the run (blank lines between items)
1182            // are not list items — skip them.
1183            if !matches!(items[i], Node::ListItem { .. }) {
1184                i += 1;
1185                continue;
1186            }
1187            let lvl = level_of(&items[i]);
1188            if lvl > base {
1189                // shouldn't happen at the head; skip defensively
1190                i += 1;
1191                continue;
1192            }
1193            let item_ref = self.add_list_item(&items[i], &self_ref);
1194            // collect any deeper items that nest under this one
1195            let mut j = i + 1;
1196            while j < items.len() && level_of(&items[j]) > base {
1197                j += 1;
1198            }
1199            if j > i + 1 {
1200                let mut nested = Vec::new();
1201                self.add_sibling_lists(&items[i + 1..j], &item_ref, &mut nested);
1202                // the nested list group(s) are children of this item
1203                if let Some(idx) = ref_index(&item_ref) {
1204                    self.texts[idx]["children"]
1205                        .as_array_mut()
1206                        .unwrap()
1207                        .extend(nested);
1208                }
1209            }
1210            children.push(json!({ "$ref": item_ref }));
1211            i = j;
1212        }
1213        self.groups[group_index(&self_ref)] = json!({
1214            "self_ref": self_ref,
1215            "parent": { "$ref": parent },
1216            "children": children,
1217            "content_layer": "body",
1218            "name": "list",
1219            "label": "list",
1220        });
1221        self_ref
1222    }
1223
1224    fn add_list_item(&mut self, node: &Node, parent: &str) -> String {
1225        let Node::ListItem {
1226            ordered,
1227            number,
1228            text,
1229            location,
1230            ..
1231        } = node
1232        else {
1233            unreachable!()
1234        };
1235        self.adopt_loc(*location);
1236        let self_ref = format!("#/texts/{}", self.texts.len());
1237        let raw = unescape_text(text);
1238        let prov = self.take_prov(raw.chars().count());
1239        let marker = if *ordered {
1240            format!("{number}.")
1241        } else {
1242            "-".to_string()
1243        };
1244        self.texts.push(json!({
1245            "self_ref": self_ref,
1246            "parent": { "$ref": parent },
1247            "children": [],
1248            "content_layer": "body",
1249            "label": "list_item",
1250            "prov": prov,
1251            "orig": raw,
1252            "text": raw,
1253            "enumerated": ordered,
1254            "marker": marker,
1255        }));
1256        self_ref
1257    }
1258
1259    fn add_table(&mut self, t: &Table, parent: &str) -> String {
1260        self.add_table_with(t, parent, false)
1261    }
1262
1263    /// [`Self::add_table`]; `raw` cell text is written verbatim (see
1264    /// [`table_data_with`]).
1265    fn add_table_with(&mut self, t: &Table, parent: &str, raw: bool) -> String {
1266        let self_ref = format!("#/tables/{}", self.tables.len());
1267        self.adopt_loc(t.location);
1268        let prov = self.take_prov(0);
1269        // The caption is a separate text item the table references (docling's
1270        // `TableItem.captions`), added before the grid so its box isn't
1271        // inherited by a later item.
1272        let (captions, children) = match t.caption.as_deref().filter(|c| !c.is_empty()) {
1273            Some(cap) => self.add_caption(cap, json!({}), &self_ref, parent, t.caption_parent),
1274            None => (Vec::new(), Vec::new()),
1275        };
1276        let data = table_data_with(t, raw);
1277        self.tables.push(json!({
1278            "self_ref": self_ref,
1279            "parent": { "$ref": parent },
1280            "children": children,
1281            "content_layer": "body",
1282            "label": "table",
1283            "prov": prov,
1284            "captions": captions,
1285            "references": [],
1286            "footnotes": [],
1287            "data": data,
1288            "annotations": [],
1289        }));
1290        self_ref
1291    }
1292
1293    /// Add a picture's or table's caption text item where `choice` says it
1294    /// hangs (#390), returning the `captions` entry for the item and the
1295    /// item's own `children` (the caption, when it is the item's child).
1296    /// The caption never consumes the item's pending provenance — the item
1297    /// takes its box first.
1298    fn add_caption(
1299        &mut self,
1300        text: &str,
1301        extra: Value,
1302        self_ref: &str,
1303        parent: &str,
1304        choice: CaptionParent,
1305    ) -> (Vec<Value>, Vec<Value>) {
1306        // docling's PDF pipeline parents the caption to the item; every
1307        // declarative backend leaves `add_text`'s default — the body — even
1308        // for an item inside a group; the office backends and HTML's
1309        // `<figure>` hang it off the item's container.
1310        let cap_parent = match choice {
1311            CaptionParent::Item => self_ref,
1312            CaptionParent::Container | CaptionParent::ContainerAfter => parent,
1313            CaptionParent::Body => "#/body",
1314        };
1315        let cap_ref = json!({ "$ref": self.add_text("caption", text, cap_parent, extra) });
1316        match choice {
1317            CaptionParent::Item => return (vec![cap_ref.clone()], vec![cap_ref]),
1318            // Created ahead of the item, so it precedes the item in the
1319            // container's children — and, on the body, in the body's.
1320            CaptionParent::Container => self.pending_siblings.push(cap_ref.clone()),
1321            CaptionParent::Body if parent == "#/body" => {
1322                self.pending_siblings.push(cap_ref.clone())
1323            }
1324            CaptionParent::ContainerAfter => self.pending_after.push(cap_ref.clone()),
1325            // The item sits deeper: the body's children get the caption after
1326            // the top-level item under walk, where docling appended it.
1327            CaptionParent::Body => self.pending_body.push(cap_ref.clone()),
1328        }
1329        (vec![cap_ref], Vec::new())
1330    }
1331
1332    /// `meta` is the picture's docling `PictureMeta` (a classifier's
1333    /// predictions, a chart's kind and data), `None` for a plain picture.
1334    fn add_picture(
1335        &mut self,
1336        caption: Option<&str>,
1337        caption_href: Option<&str>,
1338        image: Option<&crate::PictureImage>,
1339        meta: Option<Value>,
1340        parent: &str,
1341        caption_parent: CaptionParent,
1342    ) -> String {
1343        let self_ref = format!("#/pictures/{}", self.pictures.len());
1344        // Take the picture's own provenance before the caption text is added —
1345        // the caption is a separate item and must not inherit the crop's box.
1346        let prov = self.take_prov(0);
1347        let (captions, children) = match caption.filter(|c| !c.is_empty()) {
1348            Some(cap) => {
1349                // Emit the caption as a text item that the picture references. A
1350                // wrapping `<a href>`'s link rides as docling's `hyperlink` field
1351                // on the caption item (#328).
1352                let extra = match caption_href {
1353                    Some(href) => json!({ "hyperlink": href }),
1354                    None => json!({}),
1355                };
1356                self.add_caption(cap, extra, &self_ref, parent, caption_parent)
1357            }
1358            None => (Vec::new(), Vec::new()),
1359        };
1360        self.push_picture(prov, captions, children, image, meta, parent)
1361    }
1362
1363    /// Append the picture item itself — `prov`, `captions` and `children`
1364    /// (a PDF caption is the picture's child) already settled.
1365    fn push_picture(
1366        &mut self,
1367        prov: Value,
1368        captions: Vec<Value>,
1369        children: Vec<Value>,
1370        image: Option<&crate::PictureImage>,
1371        meta: Option<Value>,
1372        parent: &str,
1373    ) -> String {
1374        let self_ref = format!("#/pictures/{}", self.pictures.len());
1375        // The legacy `classification` annotation rides along with a
1376        // classifier's `meta` (see `classification_meta`); a chart's meta has
1377        // none, like docling's.
1378        let annotations = meta
1379            .as_ref()
1380            .and_then(|m| m.get("annotations").cloned())
1381            .unwrap_or_else(|| json!([]));
1382        let meta = meta.map(|mut m| {
1383            if let Some(obj) = m.as_object_mut() {
1384                obj.remove("annotations");
1385            }
1386            m
1387        });
1388        // `meta` sits between `content_layer` and `label` in docling's field
1389        // order (and `preserve_order` keeps ours byte-compatible), so the item
1390        // is built in one shot per shape rather than patched afterwards.
1391        let mut item = match meta {
1392            Some(meta) => json!({
1393                "self_ref": self_ref,
1394                "parent": { "$ref": parent },
1395                "children": children,
1396                "content_layer": "body",
1397                "meta": meta,
1398                "label": "picture",
1399                "prov": prov,
1400                "captions": captions,
1401                "references": [],
1402                "footnotes": [],
1403                "annotations": annotations,
1404            }),
1405            None => json!({
1406                "self_ref": self_ref,
1407                "parent": { "$ref": parent },
1408                "children": children,
1409                "content_layer": "body",
1410                "label": "picture",
1411                "prov": prov,
1412                "captions": captions,
1413                "references": [],
1414                "footnotes": [],
1415                "annotations": annotations,
1416            }),
1417        };
1418        // docling stores the extracted image as an `ImageRef` (data URI + size,
1419        // the size as floats) between `footnotes` and `annotations` — pydantic
1420        // field order, which `preserve_order` lets us reproduce by rebuilding
1421        // the tail.
1422        if let Some(img) = image {
1423            let image = json!({
1424                "mimetype": img.mimetype,
1425                "dpi": 72,
1426                "size": { "width": img.width as f64, "height": img.height as f64 },
1427                "uri": img.data_uri(),
1428            });
1429            if let Some(obj) = item.as_object_mut() {
1430                let annotations = obj.remove("annotations").unwrap_or_else(|| json!([]));
1431                obj.insert("image".into(), image);
1432                obj.insert("annotations".into(), annotations);
1433            }
1434        }
1435        self.pictures.push(item);
1436        self_ref
1437    }
1438
1439    fn add_group(
1440        &mut self,
1441        label: &str,
1442        name: Option<&str>,
1443        layer: Option<ContentLayer>,
1444        nodes: &[Node],
1445        parent: &str,
1446    ) -> String {
1447        let self_ref = format!("#/groups/{}", self.groups.len());
1448        self.groups.push(Value::Null);
1449        // Everything the walk creates belongs to this group, so a non-body
1450        // layer (a hidden sheet) is stamped on the whole subtree afterwards —
1451        // docling puts the layer on the group *and* on every item under it.
1452        let mark = (
1453            self.texts.len(),
1454            self.tables.len(),
1455            self.pictures.len(),
1456            self.groups.len(),
1457        );
1458        let children = self.walk_into(nodes, &self_ref);
1459        let name = name.unwrap_or(if label == "inline" { "group" } else { label });
1460        let content_layer = layer.map_or("body", |l| l.value());
1461        self.groups[group_index(&self_ref)] = json!({
1462            "self_ref": self_ref,
1463            "parent": { "$ref": parent },
1464            "children": children,
1465            "content_layer": content_layer,
1466            "name": name,
1467            "label": label,
1468        });
1469        if layer.is_some() {
1470            let (t, tb, p, g) = mark;
1471            for item in self.texts[t..]
1472                .iter_mut()
1473                .chain(self.tables[tb..].iter_mut())
1474                .chain(self.pictures[p..].iter_mut())
1475                .chain(self.groups[g..].iter_mut())
1476            {
1477                if let Some(obj) = item.as_object_mut() {
1478                    obj.insert("content_layer".into(), json!(content_layer));
1479                }
1480            }
1481        }
1482        self_ref
1483    }
1484
1485    /// Walk a slice of sibling nodes, returning each child's `$ref`; runs of
1486    /// list items are folded into list groups (one per sibling list).
1487    fn walk_into(&mut self, nodes: &[Node], parent: &str) -> Vec<Value> {
1488        // Siblings that all carry a creation rank (an XLSX sheet's items) are
1489        // *added* in that order — so `#/tables/N` and friends are numbered as
1490        // docling numbers them — while their refs keep the node order, which
1491        // is docling's position-sorted `children`.
1492        let seqs: Option<Vec<usize>> = nodes
1493            .iter()
1494            .map(|n| match n {
1495                Node::Prov { seq: Some(s), .. } => Some(*s),
1496                _ => None,
1497            })
1498            .collect();
1499        if let Some(seqs) = seqs.filter(|s| !s.is_empty()) {
1500            let mut order: Vec<usize> = (0..nodes.len()).collect();
1501            order.sort_by_key(|&i| seqs[i]);
1502            let mut slots: Vec<Vec<Value>> = vec![Vec::new(); nodes.len()];
1503            for i in order {
1504                if let Some(r) = self.add_node(&nodes[i], parent) {
1505                    slots[i].append(&mut self.pending_siblings);
1506                    slots[i].push(json!({ "$ref": r }));
1507                    slots[i].append(&mut self.pending_after);
1508                }
1509                if parent == "#/body" {
1510                    slots[i].append(&mut self.pending_body);
1511                }
1512            }
1513            return slots.into_iter().flatten().collect();
1514        }
1515        let mut children = Vec::new();
1516        let mut i = 0;
1517        while i < nodes.len() {
1518            if matches!(nodes[i], Node::ListItem { .. }) {
1519                let start = i;
1520                i += 1;
1521                loop {
1522                    match nodes.get(i) {
1523                        Some(Node::ListItem { .. }) => i += 1,
1524                        // Absorb an empty paragraph sitting between two list
1525                        // items (docling keeps the ListGroup contiguous).
1526                        Some(Node::Paragraph { text })
1527                            if text.is_empty()
1528                                && matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
1529                        {
1530                            i += 1
1531                        }
1532                        _ => break,
1533                    }
1534                }
1535                self.add_sibling_lists(&nodes[start..i], parent, &mut children);
1536            } else {
1537                if let Some(r) = self.add_node(&nodes[i], parent) {
1538                    children.append(&mut self.pending_siblings);
1539                    children.push(json!({ "$ref": r }));
1540                    children.append(&mut self.pending_after);
1541                }
1542                i += 1;
1543            }
1544            // Body-parented captions of items deeper in the tree follow the
1545            // top-level item they were created under (#390).
1546            if parent == "#/body" {
1547                children.append(&mut self.pending_body);
1548            }
1549        }
1550        children
1551    }
1552
1553    /// A run of list items may hold several *sibling* lists; emit one list group
1554    /// per sibling. The boundary is the backend's `first_in_list` flag on a
1555    /// base-level item — the same rule as the Markdown serializer's blank line
1556    /// (#385; the kind-flip and number-gap guesses are gone).
1557    fn add_sibling_lists(&mut self, run: &[Node], parent: &str, out: &mut Vec<Value>) {
1558        let base = level_of(&run[0]);
1559        let mut seg = 0;
1560        for k in 0..run.len() {
1561            let Node::ListItem {
1562                first_in_list,
1563                level,
1564                ..
1565            } = &run[k]
1566            else {
1567                continue;
1568            };
1569            if *level != base {
1570                continue; // nested item — handled inside add_list
1571            }
1572            if k > seg && *first_in_list {
1573                out.push(json!({ "$ref": self.add_list(&run[seg..k], parent) }));
1574                seg = k;
1575            }
1576        }
1577        out.push(json!({ "$ref": self.add_list(&run[seg..], parent) }));
1578    }
1579}
1580
1581fn level_of(node: &Node) -> u8 {
1582    match node {
1583        Node::ListItem { level, .. } => *level,
1584        _ => 0,
1585    }
1586}
1587
1588fn group_index(self_ref: &str) -> usize {
1589    self_ref.rsplit('/').next().unwrap().parse().unwrap()
1590}
1591
1592fn ref_index(self_ref: &str) -> Option<usize> {
1593    self_ref.rsplit('/').next()?.parse().ok()
1594}
1595
1596/// Merge the key/values of `extra` (an object) into `target` (an object).
1597fn merge(target: &mut Value, extra: Value) {
1598    if let (Some(t), Some(e)) = (target.as_object_mut(), extra.as_object()) {
1599        for (k, v) in e {
1600            t.insert(k.clone(), v.clone());
1601        }
1602    }
1603}
1604
1605/// Reverse [`crate`]'s Markdown text escaping (HTML entities + `\_`).
1606fn unescape_text(s: &str) -> String {
1607    s.replace("&lt;", "<")
1608        .replace("&gt;", ">")
1609        .replace("&amp;", "&")
1610        .replace("\\_", "_")
1611}
1612
1613/// 64-bit FNV-1a, a stand-in for docling's `binary_hash` (we lack the source bytes
1614/// at export time; the value only needs to be a stable u64).
1615fn fnv1a(s: &str) -> u64 {
1616    let mut h: u64 = 0xcbf29ce484222325;
1617    for b in s.bytes() {
1618        h ^= b as u64;
1619        h = h.wrapping_mul(0x100000001b3);
1620    }
1621    h
1622}
1623
1624#[cfg(test)]
1625mod tests {
1626    use crate::{
1627        CaptionParent, ContentLayer, DoclingDocument, ImageMode, Node, PictureImage, Table,
1628    };
1629    use serde_json::Value;
1630
1631    fn doc_with_image() -> DoclingDocument {
1632        let mut doc = DoclingDocument::new("t");
1633        doc.push(Node::Picture {
1634            caption: Some("Fig 1".into()),
1635            caption_href: None,
1636            image: Some(PictureImage {
1637                mimetype: "image/png".into(),
1638                width: 4,
1639                height: 2,
1640                data: b"foobar".to_vec(),
1641            }),
1642            classification: None,
1643            caption_parent: Default::default(),
1644        });
1645        doc
1646    }
1647
1648    /// #402: a deck's speaker notes are content, and docling puts them in the
1649    /// JSON on the `notes` layer so a consumer reading only JSON can pick them
1650    /// out. Markdown still serializes the body layer alone, and page furniture
1651    /// stays out of the JSON, where docling does keep it.
1652    #[test]
1653    fn notes_layer_items_reach_the_json_but_furniture_does_not() {
1654        let mut doc = DoclingDocument::new("t");
1655        doc.push(Node::Heading {
1656            level: 1,
1657            text: "Slide One".into(),
1658        });
1659        doc.push(Node::Furniture {
1660            layer: ContentLayer::Notes,
1661            inner: Box::new(Node::Located {
1662                location: [0, 0, 0, 0],
1663                inner: Box::new(Node::Paragraph {
1664                    text: "Speaker note for slide 1.".into(),
1665                }),
1666            }),
1667        });
1668        doc.push(Node::Furniture {
1669            layer: ContentLayer::Furniture,
1670            inner: Box::new(Node::Paragraph {
1671                text: "page header".into(),
1672            }),
1673        });
1674
1675        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1676        let texts = v["texts"].as_array().unwrap();
1677        assert_eq!(
1678            texts
1679                .iter()
1680                .map(|t| (
1681                    t["label"].as_str().unwrap(),
1682                    t["content_layer"].as_str().unwrap(),
1683                    t["text"].as_str().unwrap()
1684                ))
1685                .collect::<Vec<_>>(),
1686            vec![
1687                ("title", "body", "Slide One"),
1688                ("text", "notes", "Speaker note for slide 1."),
1689            ],
1690            "the note is carried on its own layer; the furniture is not carried"
1691        );
1692        // The body layer is what Markdown serializes, so it does not change.
1693        assert_eq!(doc.export_to_markdown(), "# Slide One\n");
1694    }
1695
1696    /// #410: a backend that describes merged ranges only as continuation
1697    /// flags (xlsx `<mergeCell>`, docx `gridSpan`/`vMerge`) gets docling's
1698    /// one-`TableCell`-per-range JSON: the anchor's offsets and spans, the
1699    /// entry repeated across the grid positions it covers — not a 1×1 cell
1700    /// per position with the text copied into each.
1701    #[test]
1702    fn continuation_flags_become_spanning_cells() {
1703        let mut doc = DoclingDocument::new("t");
1704        // A1:C2 merged ("merged"), then a plain row underneath.
1705        let rows = vec![
1706            vec!["merged".to_string(), "merged".into(), "merged".into()],
1707            vec!["merged".to_string(), "merged".into(), "merged".into()],
1708            vec!["a".to_string(), "b".into(), "c".into()],
1709        ];
1710        doc.push(Node::Table(crate::Table {
1711            rows,
1712            location: None,
1713            structure: Some(crate::TableStructure {
1714                header_row: vec![true, false, false],
1715                col_continuation: vec![
1716                    vec![false, true, true],
1717                    vec![false, true, true],
1718                    vec![false, false, false],
1719                ],
1720                row_continuation: vec![
1721                    vec![false, false, false],
1722                    vec![true, true, true],
1723                    vec![false, false, false],
1724                ],
1725                row_header: Vec::new(),
1726                col_header: Vec::new(),
1727            }),
1728            cell_blocks: None,
1729            cells: None,
1730            caption: None,
1731            caption_parent: Default::default(),
1732        }));
1733        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1734        let data = &v["tables"][0]["data"];
1735        assert_eq!(data["num_rows"], 3);
1736        assert_eq!(data["num_cols"], 3);
1737        let cells = data["table_cells"].as_array().unwrap();
1738        assert_eq!(
1739            cells.len(),
1740            4,
1741            "one cell for the range, three for the plain row"
1742        );
1743        assert_eq!(
1744            cells[0],
1745            serde_json::json!({
1746                "row_span": 2, "col_span": 3,
1747                "start_row_offset_idx": 0, "end_row_offset_idx": 2,
1748                "start_col_offset_idx": 0, "end_col_offset_idx": 3,
1749                "text": "merged", "column_header": true, "row_header": false,
1750                "row_section": false, "fillable": false,
1751            })
1752        );
1753        assert_eq!(cells[1]["text"], "a");
1754        assert_eq!(cells[1]["row_span"], 1);
1755        assert_eq!(cells[1]["column_header"], false);
1756        // The grid repeats the range's entry at every position it covers.
1757        let grid = data["grid"].as_array().unwrap();
1758        assert_eq!(grid.len(), 3);
1759        for (r, row) in grid.iter().take(2).enumerate() {
1760            for (c, cell) in row.as_array().unwrap().iter().enumerate() {
1761                assert_eq!(*cell, cells[0], "grid[{r}][{c}]");
1762            }
1763        }
1764        assert_eq!(grid[2][2]["text"], "c");
1765    }
1766
1767    /// A [`Node::Prov`] wrapper is docling's provenance verbatim — the exact
1768    /// box in a top-left origin, the backend's charspan — and the page marker
1769    /// before it sizes the page; a chart's caption becomes a sibling of the
1770    /// picture in the container, listed first, sharing the chart's box with
1771    /// a charspan over its text. That is the JSON shape of an XLSX sheet.
1772    #[test]
1773    fn exact_provenance_pages_and_chart_captions_follow_docling() {
1774        let mut doc = DoclingDocument::new("t");
1775        doc.push(Node::PageInfo {
1776            page_no: 1,
1777            width: 3.0,
1778            height: 4.0,
1779        });
1780        let table = crate::Table {
1781            rows: vec![vec!["a".to_string(), "b".into()]],
1782            ..Default::default()
1783        };
1784        doc.push(Node::Group {
1785            label: "sheet".into(),
1786            name: Some("Data".into()),
1787            layer: None,
1788            children: vec![
1789                // Node order is the position-sorted one; creation order (the
1790                // `seq`) had the chart first — so the chart is `#/pictures/0`
1791                // *and* its caption `#/texts/0`, while the table stays the
1792                // group's first child.
1793                Node::Prov {
1794                    page_no: 1,
1795                    bbox: [0.0, 0.0, 3.0, 4.0],
1796                    charspan: [0, 0],
1797                    seq: Some(1),
1798                    inner: Box::new(Node::Table(table.clone())),
1799                },
1800                Node::Prov {
1801                    page_no: 1,
1802                    bbox: [0.0, 1.0, 1.0, 1.0],
1803                    charspan: [0, 0],
1804                    seq: Some(0),
1805                    inner: Box::new(Node::Chart {
1806                        kind: "bar_chart".into(),
1807                        table,
1808                        caption: Some("Sales".into()),
1809                        location: Some([0, 128, 170, 128]),
1810                    }),
1811                },
1812            ],
1813        });
1814        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1815        assert_eq!(
1816            v["pages"],
1817            serde_json::json!({"1": {"size": {"width": 3.0, "height": 4.0}, "page_no": 1}})
1818        );
1819        assert_eq!(
1820            v["tables"][0]["prov"],
1821            serde_json::json!([{
1822                "page_no": 1,
1823                "bbox": {"l": 0.0, "t": 0.0, "r": 3.0, "b": 4.0, "coord_origin": "TOPLEFT"},
1824                "charspan": [0, 0],
1825            }])
1826        );
1827        assert_eq!(v["tables"][0]["data"]["orientation"], "rot_0");
1828        // The caption is the group's child *before* the picture, parented to
1829        // the group, and referenced by the picture.
1830        let sheet = &v["groups"][0];
1831        assert_eq!(
1832            sheet["children"],
1833            serde_json::json!([
1834                {"$ref": "#/tables/0"}, {"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}
1835            ])
1836        );
1837        let cap = &v["texts"][0];
1838        assert_eq!(cap["label"], "caption");
1839        assert_eq!(cap["parent"], serde_json::json!({"$ref": "#/groups/0"}));
1840        assert_eq!(cap["prov"][0]["charspan"], serde_json::json!([0, 5]));
1841        assert_eq!(cap["prov"][0]["bbox"]["b"], 1.0);
1842        let pic = &v["pictures"][0];
1843        assert_eq!(pic["captions"], serde_json::json!([{"$ref": "#/texts/0"}]));
1844        assert_eq!(pic["prov"][0]["charspan"], serde_json::json!([0, 0]));
1845        assert_eq!(pic["prov"][0]["bbox"]["coord_origin"], "TOPLEFT");
1846        assert_eq!(
1847            pic["meta"]["classification"]["predictions"][0]["class_name"],
1848            "bar_chart"
1849        );
1850        assert_eq!(pic["meta"]["tabular_chart"]["chart_data"]["num_cols"], 2);
1851    }
1852
1853    /// An all-zero location is the "no geometry" sentinel — a slide's speaker
1854    /// notes carry one — and docling writes it as a zero bbox, not as a box
1855    /// spanning the whole page, which is what denormalizing the grid gives.
1856    #[test]
1857    fn a_zero_location_is_a_zero_bbox_not_the_whole_page() {
1858        let mut doc = DoclingDocument::new("t");
1859        doc.push(Node::PageInfo {
1860            page_no: 1,
1861            width: 12192000.0,
1862            height: 6858000.0,
1863        });
1864        doc.push(Node::Furniture {
1865            layer: ContentLayer::Notes,
1866            inner: Box::new(Node::Located {
1867                location: [0, 0, 0, 0],
1868                inner: Box::new(Node::Paragraph {
1869                    text: "a note".into(),
1870                }),
1871            }),
1872        });
1873        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1874        let prov = &v["texts"][0]["prov"][0];
1875        assert_eq!(prov["page_no"], 1);
1876        assert_eq!(prov["charspan"], serde_json::json!([0, 6]));
1877        assert_eq!(
1878            prov["bbox"],
1879            serde_json::json!({"l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT"})
1880        );
1881        // The page itself is recorded at its true size.
1882        assert_eq!(
1883            v["pages"]["1"]["size"],
1884            serde_json::json!({"width": 12192000.0, "height": 6858000.0})
1885        );
1886    }
1887
1888    /// #171: PageInfo markers become the `pages` map, and `Located` wrappers /
1889    /// node-level locations become per-item `prov` — the 0–511 grid
1890    /// denormalized against the page into BOTTOMLEFT points. Without markers
1891    /// (every declarative backend) the JSON stays exactly as before: empty
1892    /// `pages`, `prov: []` even for located nodes.
1893    #[test]
1894    fn page_markers_produce_pages_and_prov() {
1895        let mut doc = DoclingDocument::new("t");
1896        doc.push(Node::PageInfo {
1897            page_no: 1,
1898            width: 512.0,
1899            height: 1024.0,
1900        });
1901        doc.push(Node::Located {
1902            location: [128, 64, 256, 128], // quarter/eighth points of the grid
1903            inner: Box::new(Node::Paragraph {
1904                text: "hello".into(),
1905            }),
1906        });
1907        doc.push(Node::Table(Table {
1908            rows: vec![vec!["a".into()]],
1909            location: Some([0, 0, 512, 512]),
1910            ..Table::default()
1911        }));
1912        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1913        assert_eq!(v["pages"]["1"]["page_no"], 1);
1914        assert_eq!(v["pages"]["1"]["size"]["width"], 512.0);
1915        assert_eq!(v["pages"]["1"]["size"]["height"], 1024.0);
1916        // 512-wide page: grid x scales 1:1; 1024-high: grid y doubles, then
1917        // flips to the BOTTOMLEFT origin (t from grid-top 64 → 1024-128=896).
1918        let prov = &v["texts"][0]["prov"][0];
1919        assert_eq!(prov["page_no"], 1);
1920        assert_eq!(prov["bbox"]["l"], 128.0);
1921        assert_eq!(prov["bbox"]["t"], 896.0);
1922        assert_eq!(prov["bbox"]["r"], 256.0);
1923        assert_eq!(prov["bbox"]["b"], 768.0);
1924        assert_eq!(prov["bbox"]["coord_origin"], "BOTTOMLEFT");
1925        assert_eq!(prov["charspan"][1], 5);
1926        // The table adopts its own location field; charspan is [0, 0].
1927        let tprov = &v["tables"][0]["prov"][0];
1928        assert_eq!(tprov["bbox"]["t"], 1024.0);
1929        assert_eq!(tprov["bbox"]["b"], 0.0);
1930        assert_eq!(tprov["charspan"][1], 0);
1931
1932        // No markers → the pre-#171 shape, byte for byte.
1933        let mut plain = DoclingDocument::new("t");
1934        plain.push(Node::Located {
1935            location: [1, 2, 3, 4],
1936            inner: Box::new(Node::Paragraph { text: "x".into() }),
1937        });
1938        let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
1939        assert_eq!(v["pages"], serde_json::json!({}));
1940        assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
1941    }
1942
1943    #[test]
1944    fn picture_image_in_markdown_modes_and_json() {
1945        let doc = doc_with_image();
1946        // placeholder (default) ignores the image
1947        assert!(doc.export_to_markdown().contains("<!-- image -->"));
1948        // embedded → base64 data URI (b"foobar" → "Zm9vYmFy")
1949        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Embedded, "artifacts");
1950        assert!(
1951            md.contains("![Image](data:image/png;base64,Zm9vYmFy)"),
1952            "got:\n{md}"
1953        );
1954        assert!(files.is_empty());
1955        // referenced → file link + collected bytes
1956        let (md, files) = doc.export_to_markdown_with_images(ImageMode::Referenced, "artifacts");
1957        assert!(
1958            md.contains("![Image](artifacts/image_000000.png)"),
1959            "got:\n{md}"
1960        );
1961        assert_eq!(
1962            files,
1963            vec![("artifacts/image_000000.png".to_string(), b"foobar".to_vec())]
1964        );
1965        // JSON carries the ImageRef (data URI + size — floats, as docling's
1966        // `Size` is — placed before `annotations`).
1967        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
1968        assert_eq!(v["pictures"][0]["image"]["mimetype"], "image/png");
1969        assert_eq!(v["pictures"][0]["image"]["size"]["width"], 4.0);
1970        let keys: Vec<&str> = v["pictures"][0]
1971            .as_object()
1972            .unwrap()
1973            .keys()
1974            .map(String::as_str)
1975            .collect();
1976        assert_eq!(&keys[keys.len() - 2..], ["image", "annotations"]);
1977        assert_eq!(
1978            v["pictures"][0]["image"]["uri"],
1979            "data:image/png;base64,Zm9vYmFy"
1980        );
1981    }
1982
1983    #[test]
1984    fn exports_docling_schema() {
1985        let mut doc = DoclingDocument::new("t");
1986        doc.push(Node::Heading {
1987            level: 1,
1988            text: "Title".into(),
1989        });
1990        doc.push(Node::Heading {
1991            level: 2,
1992            text: "Sec".into(),
1993        });
1994        doc.push(Node::Paragraph {
1995            text: "Body &amp; more".into(),
1996        }); // markdown-escaped
1997        doc.push(Node::ListItem {
1998            ordered: false,
1999            number: 0,
2000            first_in_list: true,
2001            text: "one".into(),
2002            level: 0,
2003            marker: None,
2004            location: None,
2005            dclx: None,
2006            href: None,
2007            layer: None,
2008        });
2009        doc.push(Node::ListItem {
2010            ordered: false,
2011            number: 0,
2012            first_in_list: false,
2013            text: "two".into(),
2014            level: 0,
2015            marker: None,
2016            location: None,
2017            dclx: None,
2018            href: None,
2019            layer: None,
2020        });
2021        doc.push(Node::Table(Table {
2022            rows: vec![vec!["A".into(), "B".into()]],
2023            location: None,
2024            structure: None,
2025            cell_blocks: None,
2026            cells: None,
2027            caption: None,
2028            caption_parent: Default::default(),
2029        }));
2030
2031        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2032        assert_eq!(v["schema_name"], "DoclingDocument");
2033        assert_eq!(v["version"], "1.10.0");
2034        assert_eq!(v["texts"][0]["label"], "title");
2035        assert_eq!(v["texts"][1]["label"], "section_header");
2036        assert_eq!(v["texts"][1]["level"], 1); // heading level 2 → docling level 1
2037        assert_eq!(v["texts"][2]["text"], "Body & more"); // un-escaped for the wire format
2038                                                          // consecutive list items fold into one list group, parented to it
2039        assert_eq!(v["groups"][0]["label"], "list");
2040        assert_eq!(v["groups"][0]["children"].as_array().unwrap().len(), 2);
2041        assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
2042        assert_eq!(v["texts"][3]["marker"], "-");
2043        // table grid + header flag
2044        assert_eq!(v["tables"][0]["data"]["num_cols"], 2);
2045        assert_eq!(v["tables"][0]["data"]["grid"][0][0]["column_header"], true);
2046    }
2047    /// A named group on a non-body layer — docling's hidden spreadsheet sheet:
2048    /// the group carries the sheet's name and the `invisible` layer, and every
2049    /// item inside it carries the layer too.
2050    #[test]
2051    fn a_layered_group_stamps_its_whole_subtree() {
2052        let doc = DoclingDocument {
2053            name: "s".into(),
2054            nodes: vec![
2055                Node::Group {
2056                    label: "sheet".into(),
2057                    name: Some("Sheet1".into()),
2058                    layer: None,
2059                    children: vec![Node::Paragraph {
2060                        text: "visible".into(),
2061                    }],
2062                },
2063                Node::Group {
2064                    label: "sheet".into(),
2065                    name: Some("Sheet2".into()),
2066                    layer: Some(ContentLayer::Invisible),
2067                    children: vec![Node::Paragraph {
2068                        text: "hidden".into(),
2069                    }],
2070                },
2071            ],
2072            ..DoclingDocument::new("s")
2073        };
2074        let v = crate::json::to_json(&doc);
2075        assert_eq!(v["groups"][0]["label"], "sheet");
2076        assert_eq!(v["groups"][0]["name"], "Sheet1");
2077        assert_eq!(v["groups"][0]["content_layer"], "body");
2078        assert_eq!(v["texts"][0]["content_layer"], "body");
2079        assert_eq!(v["groups"][1]["name"], "Sheet2");
2080        assert_eq!(v["groups"][1]["content_layer"], "invisible");
2081        assert_eq!(v["texts"][1]["content_layer"], "invisible");
2082        // The group's children are the items, and the body holds the groups.
2083        assert_eq!(v["groups"][1]["children"][0]["$ref"], "#/texts/1");
2084        assert_eq!(v["body"]["children"][1]["$ref"], "#/groups/1");
2085    }
2086
2087    /// A backend-built item tree is serialized as it is: items numbered in
2088    /// creation order per bucket (a field region's part texts included), the
2089    /// tree's parents / children / layers, docling's field order for
2090    /// `formatting`, `hyperlink`, `level`, `enumerated`/`marker`, a rich
2091    /// cell's `ref` on `table_cells` only, raw cell text.
2092    #[test]
2093    fn a_backend_item_tree_is_written_verbatim() {
2094        use crate::tree::{Formatting, ItemTree, ListMeta, TreeKind};
2095        let mut t = ItemTree::default();
2096        let text = |label: &str, txt: &str| TreeKind::Text {
2097            label: label.into(),
2098            text: txt.into(),
2099            orig: None,
2100            formatting: None,
2101            hyperlink: None,
2102            level: None,
2103            list: None,
2104        };
2105        let title = t.add(None, Some(ContentLayer::Furniture), text("title", "Page"));
2106        let h = t.add(None, None, text("title", "Heading"));
2107        let group = t.add(
2108            Some(h),
2109            None,
2110            TreeKind::Group {
2111                label: "inline".into(),
2112                name: "group".into(),
2113            },
2114        );
2115        t.add(
2116            Some(group),
2117            None,
2118            TreeKind::Text {
2119                label: "text".into(),
2120                text: "bold".into(),
2121                orig: None,
2122                formatting: Some(Formatting {
2123                    bold: true,
2124                    ..Formatting::default()
2125                }),
2126                hyperlink: Some("https://example.com/".into()),
2127                level: None,
2128                list: None,
2129            },
2130        );
2131        t.add(
2132            Some(group),
2133            None,
2134            TreeKind::Code {
2135                text: "x = 1".into(),
2136                orig: None,
2137                language: Some("python".into()),
2138                formatting: None,
2139                hyperlink: None,
2140            },
2141        );
2142        let sub = t.add(
2143            Some(h),
2144            None,
2145            TreeKind::Text {
2146                label: "section_header".into(),
2147                text: "Sub".into(),
2148                orig: Some("Sub\u{2019}".into()),
2149                formatting: None,
2150                hyperlink: None,
2151                level: Some(1),
2152                list: None,
2153            },
2154        );
2155        t.add(
2156            Some(sub),
2157            None,
2158            TreeKind::Text {
2159                label: "list_item".into(),
2160                text: "item".into(),
2161                orig: None,
2162                formatting: None,
2163                hyperlink: None,
2164                level: None,
2165                list: Some(ListMeta {
2166                    enumerated: true,
2167                    marker: "3.".into(),
2168                }),
2169            },
2170        );
2171        let _region = t.add(
2172            Some(sub),
2173            None,
2174            TreeKind::FieldRegion {
2175                items: vec![crate::FieldItem {
2176                    marker: None,
2177                    key: Some("Name".into()),
2178                    value: Some("Duck".into()),
2179                    value_kind: Some("read_only".into()),
2180                }],
2181            },
2182        );
2183        let table = t.add(
2184            Some(sub),
2185            None,
2186            TreeKind::Table {
2187                table: Table {
2188                    rows: vec![vec!["a  \n&lt;".into(), "b".into()]],
2189                    cells: Some(vec![
2190                        crate::TableCell {
2191                            text: "a  \n&lt;".into(),
2192                            bbox: None,
2193                            start_row: 0,
2194                            start_col: 0,
2195                            row_span: 3,
2196                            col_span: 1,
2197                            column_header: false,
2198                            row_header: true,
2199                            row_section: false,
2200                        },
2201                        crate::TableCell {
2202                            text: "b".into(),
2203                            bbox: None,
2204                            start_row: 0,
2205                            start_col: 1,
2206                            row_span: 1,
2207                            col_span: 1,
2208                            column_header: false,
2209                            row_header: false,
2210                            row_section: false,
2211                        },
2212                    ]),
2213                    ..Table::default()
2214                },
2215                rich_cells: vec![(0, 1, 0)], // patched below
2216                captions: Vec::new(),
2217            },
2218        );
2219        let cell_group = t.add(
2220            Some(table),
2221            None,
2222            TreeKind::Group {
2223                label: "unspecified".into(),
2224                name: "rich_cell_group_1_0_0".into(),
2225            },
2226        );
2227        if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
2228            *rich_cells = vec![(0, 1, cell_group)];
2229        }
2230        let after = t.add(Some(sub), None, text("text", "after the region"));
2231        let _ = (title, after);
2232
2233        let doc = DoclingDocument {
2234            tree: Some(t),
2235            ..DoclingDocument::new("t")
2236        };
2237        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2238        // Creation order: Page, Heading, bold, x = 1, Sub, item, [Name, Duck], after.
2239        let texts: Vec<&str> = v["texts"]
2240            .as_array()
2241            .unwrap()
2242            .iter()
2243            .map(|t| t["text"].as_str().unwrap())
2244            .collect();
2245        assert_eq!(
2246            texts,
2247            [
2248                "Page",
2249                "Heading",
2250                "bold",
2251                "x = 1",
2252                "Sub",
2253                "item",
2254                "Name",
2255                "Duck",
2256                "after the region"
2257            ]
2258        );
2259        assert_eq!(
2260            v["body"]["children"],
2261            serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}])
2262        );
2263        assert_eq!(v["texts"][0]["content_layer"], "furniture");
2264        assert_eq!(
2265            v["texts"][1]["children"],
2266            serde_json::json!([{"$ref": "#/groups/0"}, {"$ref": "#/texts/4"}])
2267        );
2268        let bold = &v["texts"][2];
2269        assert_eq!(bold["parent"]["$ref"], "#/groups/0");
2270        let keys: Vec<&str> = bold
2271            .as_object()
2272            .unwrap()
2273            .keys()
2274            .map(String::as_str)
2275            .collect();
2276        assert_eq!(
2277            keys,
2278            [
2279                "self_ref",
2280                "parent",
2281                "children",
2282                "content_layer",
2283                "label",
2284                "prov",
2285                "orig",
2286                "text",
2287                "formatting",
2288                "hyperlink"
2289            ]
2290        );
2291        assert_eq!(
2292            bold["formatting"],
2293            serde_json::json!({"bold": true, "italic": false, "underline": false, "strikethrough": false, "script": "baseline"})
2294        );
2295        let code = &v["texts"][3];
2296        assert_eq!(code["label"], "code");
2297        assert_eq!(code["code_language"], "Python");
2298        let sub = &v["texts"][4];
2299        assert_eq!(sub["orig"], "Sub\u{2019}");
2300        assert_eq!(sub["level"], 1);
2301        let item = &v["texts"][5];
2302        let keys: Vec<&str> = item
2303            .as_object()
2304            .unwrap()
2305            .keys()
2306            .map(String::as_str)
2307            .collect();
2308        assert_eq!(
2309            keys,
2310            [
2311                "self_ref",
2312                "parent",
2313                "children",
2314                "content_layer",
2315                "label",
2316                "prov",
2317                "orig",
2318                "text",
2319                "enumerated",
2320                "marker"
2321            ]
2322        );
2323        assert_eq!(item["marker"], "3.");
2324        assert_eq!(v["texts"][7]["kind"], "read_only");
2325        assert_eq!(v["field_regions"][0]["parent"]["$ref"], "#/texts/4");
2326        let table = &v["tables"][0];
2327        assert_eq!(
2328            table["children"],
2329            serde_json::json!([{"$ref": "#/groups/1"}])
2330        );
2331        let cells = table["data"]["table_cells"].as_array().unwrap();
2332        assert_eq!(
2333            cells[0]["text"], "a  \n&lt;",
2334            "raw cell text is written verbatim"
2335        );
2336        assert_eq!(
2337            cells[0]["end_row_offset_idx"], 3,
2338            "declared spans are not clamped"
2339        );
2340        assert_eq!(cells[1]["ref"], serde_json::json!({"$ref": "#/groups/1"}));
2341        assert!(cells[0].get("ref").is_none());
2342        assert!(
2343            table["data"]["grid"][0][1].get("ref").is_none(),
2344            "the grid shows plain cells"
2345        );
2346        assert_eq!(v["groups"][1]["name"], "rich_cell_group_1_0_0");
2347    }
2348
2349    /// A comment section that links its note text rather than its group — the
2350    /// spreadsheet shape, where docling-core's `add_comment` appends the text
2351    /// item's ref to each target.
2352    #[test]
2353    fn a_comment_section_can_be_referenced_by_its_note_text() {
2354        let doc = DoclingDocument {
2355            name: "c".into(),
2356            nodes: vec![
2357                Node::Commented {
2358                    comments: vec![0],
2359                    inner: Box::new(Node::Paragraph {
2360                        text: "annotated".into(),
2361                    }),
2362                },
2363                Node::CommentSection {
2364                    name: "comment-Sheet1-A1".into(),
2365                    text: "[author: A]: note".into(),
2366                    refs_note_text: true,
2367                    grouped: true,
2368                },
2369            ],
2370            ..DoclingDocument::new("c")
2371        };
2372        let v = crate::json::to_json(&doc);
2373        assert_eq!(v["groups"][0]["name"], "comment-Sheet1-A1");
2374        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/texts/1");
2375    }
2376
2377    /// docx reviewer comments: a `comment_section` group on the notes layer
2378    /// holding the note text, and a `comments` back-ref on the annotated item —
2379    /// keyed between `prov` and `orig`, the slot docling emits it in.
2380    #[test]
2381    fn comment_sections_link_back_to_their_items() {
2382        let doc = DoclingDocument {
2383            name: "c".into(),
2384            nodes: vec![
2385                Node::Commented {
2386                    comments: vec![0],
2387                    inner: Box::new(Node::Paragraph {
2388                        text: "annotated".into(),
2389                    }),
2390                },
2391                Node::Paragraph {
2392                    text: "plain".into(),
2393                },
2394                Node::CommentSection {
2395                    name: "comment-7".into(),
2396                    text: "[time: t]: note".into(),
2397                    refs_note_text: false,
2398                    grouped: true,
2399                },
2400            ],
2401            ..DoclingDocument::new("c")
2402        };
2403        let v = crate::json::to_json(&doc);
2404        // The group is the comment section; its only child is the notes text.
2405        assert_eq!(v["groups"][0]["label"], "comment_section");
2406        assert_eq!(v["groups"][0]["name"], "comment-7");
2407        assert_eq!(v["groups"][0]["content_layer"], "notes");
2408        assert_eq!(v["groups"][0]["children"][0]["$ref"], "#/texts/2");
2409        assert_eq!(v["texts"][2]["content_layer"], "notes");
2410        // The annotated item points back at the group; the plain one has no key.
2411        assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/groups/0");
2412        assert!(v["texts"][1].get("comments").is_none());
2413        // docling's key order: … prov, comments, orig, text.
2414        let keys: Vec<&str> = v["texts"][0]
2415            .as_object()
2416            .unwrap()
2417            .keys()
2418            .map(String::as_str)
2419            .collect();
2420        assert_eq!(
2421            &keys[keys.len() - 4..],
2422            &["prov", "comments", "orig", "text"]
2423        );
2424    }
2425
2426    fn picture(caption: &str, caption_parent: CaptionParent) -> Node {
2427        Node::Picture {
2428            caption: Some(caption.into()),
2429            caption_href: None,
2430            image: None,
2431            classification: None,
2432            caption_parent,
2433        }
2434    }
2435
2436    fn group(children: Vec<Node>) -> Node {
2437        Node::Group {
2438            label: "section".into(),
2439            name: None,
2440            layer: None,
2441            children,
2442        }
2443    }
2444
2445    fn refs(v: &Value) -> Vec<&str> {
2446        v.as_array()
2447            .unwrap()
2448            .iter()
2449            .map(|r| r["$ref"].as_str().unwrap())
2450            .collect()
2451    }
2452
2453    /// #390: a declarative backend's caption is docling's `add_text` default —
2454    /// a body child, appended as it is created — wherever the picture sits:
2455    /// ahead of a top-level picture, behind the top-level item enclosing a
2456    /// nested one. The picture references it either way and has no children.
2457    #[test]
2458    fn a_body_caption_follows_the_enclosing_top_level_item() {
2459        let mut doc = DoclingDocument::new("t");
2460        doc.push(picture("top", CaptionParent::Body));
2461        doc.push(group(vec![
2462            Node::Paragraph { text: "p".into() },
2463            picture("nested", CaptionParent::Body),
2464        ]));
2465        doc.push(Node::Paragraph {
2466            text: "after".into(),
2467        });
2468        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2469        assert_eq!(
2470            refs(&v["body"]["children"]),
2471            [
2472                "#/texts/0",
2473                "#/pictures/0",
2474                "#/groups/0",
2475                "#/texts/2",
2476                "#/texts/3"
2477            ]
2478        );
2479        assert_eq!(
2480            refs(&v["groups"][0]["children"]),
2481            ["#/texts/1", "#/pictures/1"]
2482        );
2483        for (cap, pic) in [(0, 0), (2, 1)] {
2484            assert_eq!(v["texts"][cap]["label"], "caption");
2485            assert_eq!(v["texts"][cap]["parent"]["$ref"], "#/body");
2486            assert_eq!(
2487                refs(&v["pictures"][pic]["captions"]),
2488                [format!("#/texts/{cap}")]
2489            );
2490            assert_eq!(v["pictures"][pic]["children"], serde_json::json!([]));
2491        }
2492    }
2493
2494    /// The PDF pipeline's caption is the picture's (or table's) own child,
2495    /// as docling attaches a layout caption.
2496    #[test]
2497    fn an_item_caption_is_the_items_first_child() {
2498        let mut doc = DoclingDocument::new("t");
2499        doc.push(picture("fig", CaptionParent::Item));
2500        doc.push(Node::Table(Table {
2501            rows: vec![vec!["a".into()]],
2502            caption: Some("tab".into()),
2503            caption_parent: CaptionParent::Item,
2504            ..Table::default()
2505        }));
2506        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2507        assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/tables/0"]);
2508        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/pictures/0");
2509        assert_eq!(refs(&v["pictures"][0]["children"]), ["#/texts/0"]);
2510        assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
2511        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/tables/0");
2512        assert_eq!(refs(&v["tables"][0]["children"]), ["#/texts/1"]);
2513        assert_eq!(refs(&v["tables"][0]["captions"]), ["#/texts/1"]);
2514    }
2515
2516    /// A container caption sits beside its item under the item's parent —
2517    /// ahead of it (an office chart's title) or behind it (an HTML
2518    /// `<figure>`'s table, whose figcaption docling adds after the table).
2519    #[test]
2520    fn a_container_caption_is_the_items_sibling() {
2521        let mut doc = DoclingDocument::new("t");
2522        doc.push(group(vec![
2523            picture("chart", CaptionParent::Container),
2524            Node::Table(Table {
2525                rows: vec![vec!["a".into()]],
2526                caption: Some("figcaption".into()),
2527                caption_parent: CaptionParent::ContainerAfter,
2528                ..Table::default()
2529            }),
2530        ]));
2531        let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
2532        assert_eq!(refs(&v["body"]["children"]), ["#/groups/0"]);
2533        assert_eq!(
2534            refs(&v["groups"][0]["children"]),
2535            ["#/texts/0", "#/pictures/0", "#/tables/0", "#/texts/1"]
2536        );
2537        assert_eq!(v["texts"][0]["parent"]["$ref"], "#/groups/0");
2538        assert_eq!(v["texts"][1]["parent"]["$ref"], "#/groups/0");
2539        assert_eq!(v["pictures"][0]["children"], serde_json::json!([]));
2540        assert_eq!(v["tables"][0]["children"], serde_json::json!([]));
2541    }
2542}