Skip to main content

docling_core/
tree.rs

1//! docling's **item tree**, for a backend that knows the exact shape upstream
2//! gives a document and wants the JSON export to reproduce it.
3//!
4//! [`DoclingDocument::nodes`](crate::DoclingDocument::nodes) is a flat,
5//! reading-order stream tuned for Markdown / DocLang / LaTeX; the JSON export
6//! rebuilds docling's parent/child structure from it with generic rules (runs
7//! of list items become list groups, a heading is a flat sibling of the text
8//! that follows it). Upstream's backends do not all agree on that structure:
9//! the HTML backend nests everything after a heading *under* the heading,
10//! splits a paragraph of mixed formatting into an `inline` group of one text
11//! item per formatting run, parents a rich table cell's content to a group
12//! under the table, keeps site chrome on the `furniture` layer… and numbers
13//! every item in the order it *creates* them. A backend that ports those
14//! rules call-for-call (HTML's `html_tree.rs`, DOCX's `docx_tree.rs`) records
15//! the result here — an arena of items in
16//! creation order, each with its parent and children — and the JSON export
17//! ([`DoclingDocument::export_to_json`](crate::DoclingDocument::export_to_json))
18//! serializes this tree instead of deriving one from the nodes. Every other
19//! serializer keeps reading the flat nodes, so their output is unaffected.
20
21use crate::{ContentLayer, FieldItem, PictureImage, Script, Table};
22
23/// docling-core's `Formatting`: the inline styles an item carries in JSON.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub struct Formatting {
26    pub bold: bool,
27    pub italic: bool,
28    pub underline: bool,
29    pub strikethrough: bool,
30    pub script: Script,
31}
32
33/// A `list_item`'s docling fields.
34#[derive(Debug, Clone, PartialEq, Eq, Default)]
35pub struct ListMeta {
36    pub enumerated: bool,
37    /// docling's `marker` — the HTML backend writes `""` unless an ordered
38    /// list carries an explicit `start`, then `"{n}."`.
39    pub marker: String,
40}
41
42/// What an item in the tree is. Mirrors the docling-core item classes the
43/// JSON `texts` / `groups` / `tables` / `pictures` / `field_regions` buckets
44/// hold.
45#[derive(Debug, Clone, PartialEq)]
46pub enum TreeKind {
47    /// A `TextItem` / `TitleItem` / `SectionHeaderItem` / `ListItem`, told
48    /// apart by `label` (`text`, `title`, `section_header`, `list_item`,
49    /// `caption`, `checkbox_selected`, `checkbox_unselected`, …).
50    Text {
51        label: String,
52        text: String,
53        /// docling's `orig` when it differs from `text` (the heading text
54        /// before unicode cleanup, say); `None` = same as `text`.
55        orig: Option<String>,
56        formatting: Option<Formatting>,
57        hyperlink: Option<String>,
58        /// `section_header` only: docling's heading level.
59        level: Option<u8>,
60        /// `list_item` only.
61        list: Option<ListMeta>,
62    },
63    /// A `CodeItem`.
64    Code {
65        text: String,
66        orig: Option<String>,
67        /// The language hint (a highlighter class token such as `python`),
68        /// mapped onto docling's `CodeLanguageLabel` at export; `None` →
69        /// `unknown`.
70        language: Option<String>,
71        formatting: Option<Formatting>,
72        hyperlink: Option<String>,
73    },
74    /// A `GroupItem`: `label` is docling's `GroupLabel` value (`inline`,
75    /// `list`, `section`, `unspecified`, …), `name` its name (`group`, `list`,
76    /// `ordered list`, `header-2`, `rich_cell_group_1_0_3`, …).
77    Group { label: String, name: String },
78    /// A `TableItem`. `rich_cells` marks the cells docling serialized as a
79    /// `RichTableCell`: `(row, col)` grid anchor → the group item (a child of
80    /// the table) that holds the cell's content. `captions` are caption text
81    /// items in the tree.
82    Table {
83        table: Table,
84        rich_cells: Vec<(usize, usize, usize)>,
85        captions: Vec<usize>,
86    },
87    /// A `PictureItem`, its caption text items and optional payload.
88    /// `classification` is a `PictureClassificationLabel` value written as
89    /// the picture's `meta.classification` (an HTML `<stamp>` / `<signature>`).
90    Picture {
91        captions: Vec<usize>,
92        image: Option<PictureImage>,
93        classification: Option<String>,
94        /// The prediction's `confidence`, when the backend writes one: the
95        /// DocLang deserializer stamps `1.0`; the office and HTML backends
96        /// leave it out (`None`).
97        confidence: Option<f64>,
98        /// A native chart's data grid (docling's `meta.tabular_chart.chart_data`,
99        /// the series reconstructed as a `TableData`), for a DOCX chart drawing.
100        chart: Option<Table>,
101        /// The `ImageRef.dpi` docling writes for `image` when the backend
102        /// read one from the file (python-pptx's `Image.dpi`: PIL's `dpi`
103        /// info, rounded, 72 when absent or out of 1–2048); `None` → 72,
104        /// which is what upstream's other office backends pass.
105        dpi: Option<u32>,
106    },
107    /// A form key-value region (`field_regions` / `field_items`).
108    FieldRegion { items: Vec<FieldItem> },
109    /// A `KeyValueItem` (`key_value_items`): docling's `GraphData` of key and
110    /// value cells and their links, written verbatim.
111    KeyValueGraph {
112        cells: Vec<crate::GraphCell>,
113        links: Vec<crate::GraphLink>,
114    },
115}
116
117/// docling's `ProvenanceItem` for a tree item, written verbatim: the
118/// backend's own geometry in the page's units — a PPTX shape's EMU box,
119/// whose `pages` entry is the slide size in EMU — rather than the 0–511
120/// DocLang grid the flat [`Node::Located`](crate::Node::Located) carries
121/// (which cannot round-trip those integers).
122#[derive(Debug, Clone, PartialEq)]
123pub struct TreeProv {
124    /// 1-based page (slide) number.
125    pub page_no: usize,
126    /// `[l, t, r, b]`, exactly as docling computed them.
127    pub bbox: [f64; 4],
128    /// docling's `coord_origin` tag. The office backends tag their
129    /// top-left-based boxes `TOPLEFT` (the PPTX backend since docling#4294 —
130    /// it used to tag them `BOTTOMLEFT`, which read the tuple as `(l, b, r,
131    /// t)` and swapped the vertical edges); a backend that really works in a
132    /// bottom-left space sets this.
133    pub bottom_left: bool,
134    /// `[0, len(text)]` in characters for a text item, `[0, 0]` for a table
135    /// or picture.
136    pub charspan: [usize; 2],
137}
138
139/// docling's `TrackSource` — where in a time-based track (a WebVTT cue) a
140/// text item came from. Written as the item's `source: [{"kind": "track", …}]`.
141#[derive(Debug, Clone, PartialEq)]
142pub struct TreeTrack {
143    /// The cue's start offset in seconds (docling's `WebVTTTimestamp.seconds`:
144    /// `h*3600 + m*60 + s + millis/1000.0`, so the float is bit-identical).
145    pub start_time: f64,
146    /// The cue's end offset in seconds.
147    pub end_time: f64,
148    /// The cue identifier line, when the cue has one.
149    pub identifier: Option<String>,
150    /// The `<v …>` voice annotation the text sits in, when any.
151    pub voice: Option<String>,
152}
153
154/// One item of an [`ItemTree`].
155#[derive(Debug, Clone, PartialEq)]
156pub struct TreeItem {
157    /// The parent item's index; `None` = the document body.
158    pub parent: Option<usize>,
159    /// Child item indices, in docling's `children` order.
160    pub children: Vec<usize>,
161    /// The content layer; `None` = `body`.
162    pub layer: Option<ContentLayer>,
163    pub kind: TreeKind,
164    /// The item's `prov` entry, when the backend has page geometry for it
165    /// (`None` → `prov: []`, what the HTML and DOCX backends write).
166    pub prov: Option<TreeProv>,
167    /// docling's `DocItem.comments`: the `comment_section` groups (or note
168    /// text items) annotating this item, as item indices — written after
169    /// `prov` when non-empty.
170    pub comments: Vec<usize>,
171    /// docling's `DocItem.source`: the track segment a text item was taken
172    /// from (WebVTT cues) — written after `prov` when set.
173    pub source: Option<TreeTrack>,
174    /// Removed by [`ItemTree::delete`] (docling's `delete_items`): the slot
175    /// stays so every other index keeps its meaning, but the item is not
176    /// numbered or written.
177    pub deleted: bool,
178    /// Footnotes / endnotes referenced from inside this text item (#538): the
179    /// note call's position (chars into the item's text) and the note's text.
180    /// docling keeps notes as unlinked furniture `footnote` items, so the
181    /// JSON never shows this; the Pandoc AST writes each as a `Note` there.
182    pub notes: Vec<TreeNote>,
183    /// This furniture `footnote` item is the body of a note some text item
184    /// calls (it travels in that item's [`Self::notes`]); the Pandoc AST then
185    /// leaves it out as a standalone block.
186    pub note_body: bool,
187}
188
189/// A note call inside a text item ([`TreeItem::notes`]).
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct TreeNote {
192    /// The call's position, in chars into the item's `text`.
193    pub offset: usize,
194    /// The note's plain text.
195    pub text: String,
196}
197
198impl ItemTree {
199    /// Where each note call of one paragraph lands (#538): `full_text` is the
200    /// paragraph's text, `offsets` the calls' positions in it (chars), and
201    /// the paragraph's items are those created from `first_new` on. Each text
202    /// item is a trimmed slice of the paragraph (a formatting run, a link, or
203    /// the whole heading / list item), matched in order; a call inside an
204    /// item lands there, one between items ends the earlier (or starts the
205    /// first). When no item matches, the call ends the paragraph's last text
206    /// item; with no text item at all it is `None`.
207    pub fn place_note_calls(
208        &self,
209        first_new: usize,
210        full_text: &str,
211        offsets: &[usize],
212    ) -> Vec<Option<(usize, usize)>> {
213        let texts: Vec<(usize, &str)> = (first_new..self.items.len())
214            .filter(|&i| !self.items[i].deleted)
215            .filter_map(|i| match &self.items[i].kind {
216                TreeKind::Text { text, .. } | TreeKind::Code { text, .. } => {
217                    Some((i, text.as_str()))
218                }
219                _ => None,
220            })
221            .collect();
222        // Each found item's (id, start, end) in chars of `full_text`.
223        let mut spans: Vec<(usize, usize, usize)> = Vec::new();
224        let mut cursor = 0usize; // bytes
225        for &(item, text) in &texts {
226            if text.is_empty() {
227                continue;
228            }
229            if let Some(pos) = full_text[cursor..].find(text) {
230                let start_b = cursor + pos;
231                let start = full_text[..start_b].chars().count();
232                spans.push((item, start, start + text.chars().count()));
233                cursor = start_b + text.len();
234            }
235        }
236        offsets
237            .iter()
238            .map(|&offset| {
239                spans
240                    .iter()
241                    .find(|&&(_, start, end)| offset >= start && offset <= end)
242                    .map(|&(item, start, _)| (item, offset - start))
243                    .or_else(
244                        || match spans.iter().rev().find(|&&(_, _, end)| end <= offset) {
245                            Some(&(item, start, end)) => Some((item, end - start)),
246                            None => spans.first().map(|&(item, _, _)| (item, 0)),
247                        },
248                    )
249                    .or_else(|| {
250                        texts
251                            .last()
252                            .map(|&(item, text)| (item, text.chars().count()))
253                    })
254            })
255            .collect()
256    }
257}
258
259/// docling's item tree in creation order (see the [module docs](self)).
260#[derive(Debug, Clone, PartialEq, Default)]
261pub struct ItemTree {
262    /// Every item, indexed by creation order — which is how docling numbers
263    /// `#/texts/N`, `#/groups/N`, … within each bucket.
264    pub items: Vec<TreeItem>,
265    /// The body's `children`, as item indices.
266    pub body: Vec<usize>,
267}
268
269impl ItemTree {
270    /// Append an item under `parent` (`None` = body) on `layer`, registering
271    /// it as its parent's last child — docling's `add_*` calls do exactly that.
272    pub fn add(
273        &mut self,
274        parent: Option<usize>,
275        layer: Option<ContentLayer>,
276        kind: TreeKind,
277    ) -> usize {
278        let id = self.items.len();
279        self.items.push(TreeItem {
280            parent,
281            children: Vec::new(),
282            layer,
283            kind,
284            prov: None,
285            comments: Vec::new(),
286            source: None,
287            deleted: false,
288            notes: Vec::new(),
289            note_body: false,
290        });
291        match parent {
292            Some(p) => self.items[p].children.push(id),
293            None => self.body.push(id),
294        }
295        id
296    }
297
298    /// [`add`](Self::add) with the item's provenance — docling's
299    /// `add_text(…, prov=prov)`.
300    pub fn add_with_prov(
301        &mut self,
302        parent: Option<usize>,
303        layer: Option<ContentLayer>,
304        kind: TreeKind,
305        prov: TreeProv,
306    ) -> usize {
307        let id = self.add(parent, layer, kind);
308        self.items[id].prov = Some(prov);
309        id
310    }
311
312    /// Append every item of `other` after this tree's, renumbering its
313    /// indices (parents, children, comments, table/picture caption and
314    /// rich-cell refs) and adding its body children to this body — so a
315    /// backend can build independent fragments in parallel (one per PPTX
316    /// slide) and still hand the export one tree in creation order, exactly
317    /// as if it had been built sequentially.
318    pub fn append(&mut self, other: ItemTree) {
319        let off = self.items.len();
320        let shift = |i: usize| i + off;
321        for mut item in other.items {
322            item.parent = item.parent.map(shift);
323            for c in item.children.iter_mut().chain(item.comments.iter_mut()) {
324                *c = shift(*c);
325            }
326            match &mut item.kind {
327                TreeKind::Table {
328                    rich_cells,
329                    captions,
330                    ..
331                } => {
332                    for (_, _, g) in rich_cells.iter_mut() {
333                        *g = shift(*g);
334                    }
335                    for c in captions.iter_mut() {
336                        *c = shift(*c);
337                    }
338                }
339                TreeKind::Picture { captions, .. } => {
340                    for c in captions.iter_mut() {
341                        *c = shift(*c);
342                    }
343                }
344                _ => {}
345            }
346            self.items.push(item);
347        }
348        self.body.extend(other.body.into_iter().map(shift));
349    }
350
351    /// Move `id` under `new_parent`, dropping it from its current parent's
352    /// children and appending it to the new one's — docling's
353    /// `group_cell_elements` re-parenting of a rich cell's items.
354    pub fn reparent(&mut self, id: usize, new_parent: Option<usize>) {
355        let old = self.items[id].parent;
356        let siblings = match old {
357            Some(p) => &mut self.items[p].children,
358            None => &mut self.body,
359        };
360        siblings.retain(|&c| c != id);
361        self.items[id].parent = new_parent;
362        match new_parent {
363            Some(p) => self.items[p].children.push(id),
364            None => self.body.push(id),
365        }
366    }
367
368    /// Re-number the items in traversal order — a pre-order walk of the body
369    /// through every layer, which is how docling's
370    /// `DoclingDocument.concatenate` (and `_normalize_references`) re-creates
371    /// a document's items: a group created after the content it was later
372    /// wrapped around comes before that content afterwards. Items the walk
373    /// does not reach (deleted ones) are dropped. Returns each old id's new
374    /// id.
375    pub fn renumber_in_traversal_order(&mut self) -> Vec<Option<usize>> {
376        let mut order = Vec::with_capacity(self.items.len());
377        let mut stack: Vec<usize> = self.body.iter().rev().copied().collect();
378        while let Some(id) = stack.pop() {
379            if self.items[id].deleted {
380                continue;
381            }
382            order.push(id);
383            stack.extend(self.items[id].children.iter().rev());
384        }
385        let mut new_of: Vec<Option<usize>> = vec![None; self.items.len()];
386        for (new, &old) in order.iter().enumerate() {
387            new_of[old] = Some(new);
388        }
389        let remap = |ids: &mut Vec<usize>| {
390            *ids = ids.iter().filter_map(|&i| new_of[i]).collect();
391        };
392        let mut old_items: Vec<Option<TreeItem>> = std::mem::take(&mut self.items)
393            .into_iter()
394            .map(Some)
395            .collect();
396        for &old in &order {
397            let mut item = old_items[old].take().expect("each item visited once");
398            item.parent = item.parent.and_then(|p| new_of[p]);
399            remap(&mut item.children);
400            remap(&mut item.comments);
401            match &mut item.kind {
402                TreeKind::Table {
403                    rich_cells,
404                    captions,
405                    ..
406                } => {
407                    rich_cells.retain_mut(|(_, _, g)| match new_of[*g] {
408                        Some(n) => {
409                            *g = n;
410                            true
411                        }
412                        None => false,
413                    });
414                    remap(captions);
415                }
416                TreeKind::Picture { captions, .. } => remap(captions),
417                _ => {}
418            }
419            self.items.push(item);
420        }
421        remap(&mut self.body);
422        new_of
423    }
424
425    /// Remove `id` from the tree — docling's `delete_items`, which the DOCX
426    /// backend uses to drop the empty text item a blank spacer paragraph left
427    /// between two items of a resumed list. The item leaves its parent's
428    /// children and is neither numbered nor written; its slot stays so the
429    /// indices held elsewhere stay valid.
430    pub fn delete(&mut self, id: usize) {
431        match self.items[id].parent {
432            Some(p) => self.items[p].children.retain(|&c| c != id),
433            None => self.body.retain(|&c| c != id),
434        }
435        self.items[id].deleted = true;
436    }
437
438    /// Mark `id` and its whole subtree deleted — `delete_items` drops an
439    /// item "and any children it has".
440    fn delete_subtree(&mut self, id: usize) {
441        self.delete(id);
442        let mut stack = self.items[id].children.clone();
443        while let Some(c) = stack.pop() {
444            self.items[c].deleted = true;
445            stack.extend(self.items[c].children.iter().copied());
446        }
447    }
448
449    /// docling-core's `DoclingDocument.validate_misplaced_list_items` (a
450    /// model validator, so it runs whenever docling-core serializes or loads
451    /// a document): every `list_item` whose parent is not a `list` group is
452    /// re-homed into a new one. A pre-order walk of the body (groups
453    /// included) collects them; consecutive misplaced items directly on the
454    /// body share one group, any other misplaced item gets its own. Working
455    /// from the last run back, each run gets a `ListGroup` (name `group`)
456    /// inserted where its first item stood, the items are deleted — with any
457    /// children — and re-added under the group as fresh items, so they move
458    /// to the end of the text numbering (#527: the DOCX backend leaves such
459    /// items in rich table cells). A no-op for a well-formed tree.
460    pub fn wrap_misplaced_list_items(&mut self) {
461        let is_list_item = |t: &Self, id: usize| matches!(&t.items[id].kind, TreeKind::Text { label, .. } if label == "list_item");
462        let in_list_group = |t: &Self, id: usize| {
463            t.items[id].parent.is_some_and(
464                |p| matches!(&t.items[p].kind, TreeKind::Group { label, .. } if label == "list"),
465            )
466        };
467        let mut runs: Vec<Vec<usize>> = Vec::new();
468        // `None` = the body itself, which the walk yields first.
469        let mut prev: Option<usize> = None;
470        let mut stack: Vec<usize> = self.body.iter().rev().copied().collect();
471        while let Some(id) = stack.pop() {
472            if self.items[id].deleted {
473                continue;
474            }
475            if is_list_item(self, id) && !in_list_group(self, id) {
476                let continues =
477                    prev.is_some_and(|p| is_list_item(self, p) && self.items[p].parent.is_none());
478                match runs.last_mut() {
479                    Some(run) if continues => run.push(id),
480                    _ => runs.push(vec![id]),
481                }
482            }
483            prev = Some(id);
484            stack.extend(self.items[id].children.iter().rev().copied());
485        }
486        for run in runs.into_iter().rev() {
487            let parent = self.items[run[0]].parent;
488            let group = self.add(
489                parent,
490                None,
491                TreeKind::Group {
492                    label: "list".into(),
493                    name: "group".into(),
494                },
495            );
496            let siblings = match parent {
497                Some(p) => &mut self.items[p].children,
498                None => &mut self.body,
499            };
500            siblings.pop();
501            let at = siblings
502                .iter()
503                .position(|&c| c == run[0])
504                .unwrap_or(siblings.len());
505            siblings.insert(at, group);
506            for &li in &run {
507                self.delete_subtree(li);
508            }
509            // `add_list_item` keeps the text, marker, formatting, hyperlink
510            // and first provenance — not children, comments or a source.
511            for &li in &run {
512                let copy = TreeItem {
513                    parent: Some(group),
514                    children: Vec::new(),
515                    comments: Vec::new(),
516                    source: None,
517                    deleted: false,
518                    ..self.items[li].clone()
519                };
520                let id = self.items.len();
521                self.items.push(copy);
522                self.items[group].children.push(id);
523            }
524        }
525    }
526
527    /// The last live text-bucket item (docling's `doc.texts[-1]`).
528    pub fn last_text(&self) -> Option<usize> {
529        self.items.iter().rposition(|it| {
530            !it.deleted && matches!(it.kind, TreeKind::Text { .. } | TreeKind::Code { .. })
531        })
532    }
533
534    /// How many items of a bucket precede `id` — its `#/{bucket}/N` index.
535    pub fn bucket_index(&self, id: usize) -> usize {
536        let same = |k: &TreeKind| {
537            std::mem::discriminant(k) == std::mem::discriminant(&self.items[id].kind)
538                || matches!(
539                    (k, &self.items[id].kind),
540                    (TreeKind::Text { .. }, TreeKind::Code { .. })
541                        | (TreeKind::Code { .. }, TreeKind::Text { .. })
542                )
543        };
544        self.items[..id]
545            .iter()
546            .filter(|it| !it.deleted && same(&it.kind))
547            .count()
548    }
549
550    /// The number of tables created so far (docling's `len(doc.tables)`).
551    pub fn table_count(&self) -> usize {
552        self.items
553            .iter()
554            .filter(|it| !it.deleted && matches!(it.kind, TreeKind::Table { .. }))
555            .count()
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn text(t: &str) -> TreeKind {
564        TreeKind::Text {
565            label: "text".into(),
566            text: t.into(),
567            orig: None,
568            formatting: None,
569            hyperlink: None,
570            level: None,
571            list: None,
572        }
573    }
574
575    /// `add` registers the item as its parent's (or the body's) last child;
576    /// `reparent` moves it — a rich cell's items leave the heading they were
577    /// created under for the table's group.
578    #[test]
579    fn add_and_reparent_keep_docling_children_order() {
580        let mut t = ItemTree::default();
581        let title = t.add(None, None, text("Title"));
582        let a = t.add(Some(title), None, text("a"));
583        let b = t.add(Some(title), None, text("b"));
584        let table = t.add(
585            Some(title),
586            None,
587            TreeKind::Table {
588                table: Table::default(),
589                rich_cells: Vec::new(),
590                captions: Vec::new(),
591            },
592        );
593        let group = t.add(
594            Some(table),
595            None,
596            TreeKind::Group {
597                label: "unspecified".into(),
598                name: "rich_cell_group_1_0_0".into(),
599            },
600        );
601        assert_eq!(t.body, vec![title]);
602        assert_eq!(t.items[title].children, vec![a, b, table]);
603        t.reparent(a, Some(group));
604        assert_eq!(t.items[title].children, vec![b, table]);
605        assert_eq!(t.items[group].children, vec![a]);
606        assert_eq!(t.items[a].parent, Some(group));
607        assert_eq!(t.table_count(), 1);
608        // Text and code share the `texts` bucket.
609        let code = t.add(
610            None,
611            None,
612            TreeKind::Code {
613                text: "x".into(),
614                orig: None,
615                language: None,
616                formatting: None,
617                hyperlink: None,
618            },
619        );
620        assert_eq!(t.bucket_index(code), 3, "title, a, b precede it in `texts`");
621        assert_eq!(t.bucket_index(group), 0);
622        assert_eq!(t.body, vec![title, code]);
623    }
624
625    /// `append` renumbers a fragment built on its own (a slide converted in
626    /// parallel) so the merged tree reads as if built in one pass: parents,
627    /// children, comment back-refs and caption refs all shift together.
628    #[test]
629    fn append_renumbers_a_fragment_into_creation_order() {
630        let mut whole = ItemTree::default();
631        let slide0 = whole.add(
632            None,
633            None,
634            TreeKind::Group {
635                label: "chapter".into(),
636                name: "slide-0".into(),
637            },
638        );
639        whole.add(Some(slide0), None, text("first"));
640
641        let mut frag = ItemTree::default();
642        let slide1 = frag.add(
643            None,
644            None,
645            TreeKind::Group {
646                label: "chapter".into(),
647                name: "slide-1".into(),
648            },
649        );
650        let cap = frag.add_with_prov(
651            Some(slide1),
652            None,
653            TreeKind::Text {
654                label: "caption".into(),
655                text: "Title".into(),
656                orig: None,
657                formatting: None,
658                hyperlink: None,
659                level: None,
660                list: None,
661            },
662            TreeProv {
663                page_no: 2,
664                bbox: [1.0, 2.0, 3.0, 4.0],
665                bottom_left: true,
666                charspan: [0, 5],
667            },
668        );
669        let pic = frag.add(
670            Some(slide1),
671            None,
672            TreeKind::Picture {
673                captions: vec![cap],
674                image: None,
675                classification: Some("bar_chart".into()),
676                confidence: None,
677                chart: None,
678                dpi: None,
679            },
680        );
681        let note = frag.add(
682            None,
683            Some(ContentLayer::Notes),
684            TreeKind::Group {
685                label: "comment_section".into(),
686                name: "comment-slide2-1".into(),
687            },
688        );
689        frag.items[pic].comments.push(note);
690
691        whole.append(frag);
692        assert_eq!(whole.body, vec![slide0, 2, 5]);
693        assert_eq!(whole.items[2].children, vec![3, 4]);
694        assert_eq!(whole.items[3].parent, Some(2));
695        assert_eq!(whole.items[3].prov.as_ref().map(|p| p.page_no), Some(2));
696        assert!(
697            matches!(&whole.items[4].kind, TreeKind::Picture { captions, .. } if captions == &[3])
698        );
699        assert_eq!(whole.items[4].comments, vec![5]);
700        assert_eq!(whole.items[5].parent, None);
701        assert_eq!(
702            whole.bucket_index(4),
703            0,
704            "the fragment's picture is #/pictures/0"
705        );
706        assert_eq!(
707            whole.bucket_index(5),
708            2,
709            "slide-0, slide-1 precede it in `groups`"
710        );
711    }
712
713    /// `renumber_in_traversal_order`: docling's `concatenate` re-creates the
714    /// items as a pre-order walk meets them, so a group created after the
715    /// content it was wrapped around (a rich cell's group) comes first, and a
716    /// deleted item disappears; every cross-reference follows.
717    #[test]
718    fn renumbering_follows_the_traversal() {
719        let mut t = ItemTree::default();
720        let a = t.add(None, None, text("a"));
721        let table = t.add(
722            None,
723            None,
724            TreeKind::Table {
725                table: Table::default(),
726                rich_cells: Vec::new(),
727                captions: Vec::new(),
728            },
729        );
730        let cell_text = t.add(None, None, text("cell"));
731        let group = t.add(
732            Some(table),
733            None,
734            TreeKind::Group {
735                label: "unspecified".into(),
736                name: "rich_cell_group_1_0_0".into(),
737            },
738        );
739        t.reparent(cell_text, Some(group));
740        if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
741            rich_cells.push((0, 0, group));
742        }
743        let gone = t.add(None, None, text("gone"));
744        t.delete(gone);
745        let z = t.add(None, None, text("z"));
746
747        let new_of = t.renumber_in_traversal_order();
748        assert_eq!(
749            new_of,
750            vec![Some(0), Some(1), Some(3), Some(2), None, Some(4)]
751        );
752        assert_eq!(t.items.len(), 5);
753        assert_eq!(t.body, vec![0, 1, 4]);
754        assert_eq!(t.items[1].children, vec![2], "the group follows its table");
755        assert_eq!(t.items[2].parent, Some(1));
756        assert_eq!(
757            t.items[3].parent,
758            Some(2),
759            "the cell text follows its group"
760        );
761        assert!(matches!(&t.items[3].kind, TreeKind::Text { text, .. } if text == "cell"));
762        assert!(
763            matches!(&t.items[1].kind, TreeKind::Table { rich_cells, .. } if rich_cells == &[(0, 0, 2)])
764        );
765        let _ = (a, z);
766    }
767}