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 /// A native chart's data grid (docling's `meta.tabular_chart.chart_data`,
95 /// the series reconstructed as a `TableData`), for a DOCX chart drawing.
96 chart: Option<Table>,
97 /// The `ImageRef.dpi` docling writes for `image` when the backend
98 /// read one from the file (python-pptx's `Image.dpi`: PIL's `dpi`
99 /// info, rounded, 72 when absent or out of 1–2048); `None` → 72,
100 /// which is what upstream's other office backends pass.
101 dpi: Option<u32>,
102 },
103 /// A form key-value region (`field_regions` / `field_items`).
104 FieldRegion { items: Vec<FieldItem> },
105}
106
107/// docling's `ProvenanceItem` for a tree item, written verbatim: the
108/// backend's own geometry in the page's units — a PPTX shape's EMU box,
109/// whose `pages` entry is the slide size in EMU — rather than the 0–511
110/// DocLang grid the flat [`Node::Located`](crate::Node::Located) carries
111/// (which cannot round-trip those integers).
112#[derive(Debug, Clone, PartialEq)]
113pub struct TreeProv {
114 /// 1-based page (slide) number.
115 pub page_no: usize,
116 /// `[l, t, r, b]`, exactly as docling computed them.
117 pub bbox: [f64; 4],
118 /// docling's `coord_origin` tag. `MsPowerpointDocumentBackend` builds a
119 /// shape's box with `BoundingBox.from_tuple(…, BOTTOMLEFT)` — which reads
120 /// the tuple as `(l, b, r, t)`, so the shape's top EMU lands in `b` — a
121 /// quirk the JSON keeps; a speaker note's zero box is `TOPLEFT`
122 /// (`BoundingBox`'s default).
123 pub bottom_left: bool,
124 /// `[0, len(text)]` in characters for a text item, `[0, 0]` for a table
125 /// or picture.
126 pub charspan: [usize; 2],
127}
128
129/// One item of an [`ItemTree`].
130#[derive(Debug, Clone, PartialEq)]
131pub struct TreeItem {
132 /// The parent item's index; `None` = the document body.
133 pub parent: Option<usize>,
134 /// Child item indices, in docling's `children` order.
135 pub children: Vec<usize>,
136 /// The content layer; `None` = `body`.
137 pub layer: Option<ContentLayer>,
138 pub kind: TreeKind,
139 /// The item's `prov` entry, when the backend has page geometry for it
140 /// (`None` → `prov: []`, what the HTML and DOCX backends write).
141 pub prov: Option<TreeProv>,
142 /// docling's `DocItem.comments`: the `comment_section` groups (or note
143 /// text items) annotating this item, as item indices — written after
144 /// `prov` when non-empty.
145 pub comments: Vec<usize>,
146 /// Removed by [`ItemTree::delete`] (docling's `delete_items`): the slot
147 /// stays so every other index keeps its meaning, but the item is not
148 /// numbered or written.
149 pub deleted: bool,
150}
151
152/// docling's item tree in creation order (see the [module docs](self)).
153#[derive(Debug, Clone, PartialEq, Default)]
154pub struct ItemTree {
155 /// Every item, indexed by creation order — which is how docling numbers
156 /// `#/texts/N`, `#/groups/N`, … within each bucket.
157 pub items: Vec<TreeItem>,
158 /// The body's `children`, as item indices.
159 pub body: Vec<usize>,
160}
161
162impl ItemTree {
163 /// Append an item under `parent` (`None` = body) on `layer`, registering
164 /// it as its parent's last child — docling's `add_*` calls do exactly that.
165 pub fn add(
166 &mut self,
167 parent: Option<usize>,
168 layer: Option<ContentLayer>,
169 kind: TreeKind,
170 ) -> usize {
171 let id = self.items.len();
172 self.items.push(TreeItem {
173 parent,
174 children: Vec::new(),
175 layer,
176 kind,
177 prov: None,
178 comments: Vec::new(),
179 deleted: false,
180 });
181 match parent {
182 Some(p) => self.items[p].children.push(id),
183 None => self.body.push(id),
184 }
185 id
186 }
187
188 /// [`add`](Self::add) with the item's provenance — docling's
189 /// `add_text(…, prov=prov)`.
190 pub fn add_with_prov(
191 &mut self,
192 parent: Option<usize>,
193 layer: Option<ContentLayer>,
194 kind: TreeKind,
195 prov: TreeProv,
196 ) -> usize {
197 let id = self.add(parent, layer, kind);
198 self.items[id].prov = Some(prov);
199 id
200 }
201
202 /// Append every item of `other` after this tree's, renumbering its
203 /// indices (parents, children, comments, table/picture caption and
204 /// rich-cell refs) and adding its body children to this body — so a
205 /// backend can build independent fragments in parallel (one per PPTX
206 /// slide) and still hand the export one tree in creation order, exactly
207 /// as if it had been built sequentially.
208 pub fn append(&mut self, other: ItemTree) {
209 let off = self.items.len();
210 let shift = |i: usize| i + off;
211 for mut item in other.items {
212 item.parent = item.parent.map(shift);
213 for c in item.children.iter_mut().chain(item.comments.iter_mut()) {
214 *c = shift(*c);
215 }
216 match &mut item.kind {
217 TreeKind::Table {
218 rich_cells,
219 captions,
220 ..
221 } => {
222 for (_, _, g) in rich_cells.iter_mut() {
223 *g = shift(*g);
224 }
225 for c in captions.iter_mut() {
226 *c = shift(*c);
227 }
228 }
229 TreeKind::Picture { captions, .. } => {
230 for c in captions.iter_mut() {
231 *c = shift(*c);
232 }
233 }
234 _ => {}
235 }
236 self.items.push(item);
237 }
238 self.body.extend(other.body.into_iter().map(shift));
239 }
240
241 /// Move `id` under `new_parent`, dropping it from its current parent's
242 /// children and appending it to the new one's — docling's
243 /// `group_cell_elements` re-parenting of a rich cell's items.
244 pub fn reparent(&mut self, id: usize, new_parent: Option<usize>) {
245 let old = self.items[id].parent;
246 let siblings = match old {
247 Some(p) => &mut self.items[p].children,
248 None => &mut self.body,
249 };
250 siblings.retain(|&c| c != id);
251 self.items[id].parent = new_parent;
252 match new_parent {
253 Some(p) => self.items[p].children.push(id),
254 None => self.body.push(id),
255 }
256 }
257
258 /// Remove `id` from the tree — docling's `delete_items`, which the DOCX
259 /// backend uses to drop the empty text item a blank spacer paragraph left
260 /// between two items of a resumed list. The item leaves its parent's
261 /// children and is neither numbered nor written; its slot stays so the
262 /// indices held elsewhere stay valid.
263 pub fn delete(&mut self, id: usize) {
264 match self.items[id].parent {
265 Some(p) => self.items[p].children.retain(|&c| c != id),
266 None => self.body.retain(|&c| c != id),
267 }
268 self.items[id].deleted = true;
269 }
270
271 /// The last live text-bucket item (docling's `doc.texts[-1]`).
272 pub fn last_text(&self) -> Option<usize> {
273 self.items.iter().rposition(|it| {
274 !it.deleted && matches!(it.kind, TreeKind::Text { .. } | TreeKind::Code { .. })
275 })
276 }
277
278 /// How many items of a bucket precede `id` — its `#/{bucket}/N` index.
279 pub fn bucket_index(&self, id: usize) -> usize {
280 let same = |k: &TreeKind| {
281 std::mem::discriminant(k) == std::mem::discriminant(&self.items[id].kind)
282 || matches!(
283 (k, &self.items[id].kind),
284 (TreeKind::Text { .. }, TreeKind::Code { .. })
285 | (TreeKind::Code { .. }, TreeKind::Text { .. })
286 )
287 };
288 self.items[..id]
289 .iter()
290 .filter(|it| !it.deleted && same(&it.kind))
291 .count()
292 }
293
294 /// The number of tables created so far (docling's `len(doc.tables)`).
295 pub fn table_count(&self) -> usize {
296 self.items
297 .iter()
298 .filter(|it| !it.deleted && matches!(it.kind, TreeKind::Table { .. }))
299 .count()
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 fn text(t: &str) -> TreeKind {
308 TreeKind::Text {
309 label: "text".into(),
310 text: t.into(),
311 orig: None,
312 formatting: None,
313 hyperlink: None,
314 level: None,
315 list: None,
316 }
317 }
318
319 /// `add` registers the item as its parent's (or the body's) last child;
320 /// `reparent` moves it — a rich cell's items leave the heading they were
321 /// created under for the table's group.
322 #[test]
323 fn add_and_reparent_keep_docling_children_order() {
324 let mut t = ItemTree::default();
325 let title = t.add(None, None, text("Title"));
326 let a = t.add(Some(title), None, text("a"));
327 let b = t.add(Some(title), None, text("b"));
328 let table = t.add(
329 Some(title),
330 None,
331 TreeKind::Table {
332 table: Table::default(),
333 rich_cells: Vec::new(),
334 captions: Vec::new(),
335 },
336 );
337 let group = t.add(
338 Some(table),
339 None,
340 TreeKind::Group {
341 label: "unspecified".into(),
342 name: "rich_cell_group_1_0_0".into(),
343 },
344 );
345 assert_eq!(t.body, vec![title]);
346 assert_eq!(t.items[title].children, vec![a, b, table]);
347 t.reparent(a, Some(group));
348 assert_eq!(t.items[title].children, vec![b, table]);
349 assert_eq!(t.items[group].children, vec![a]);
350 assert_eq!(t.items[a].parent, Some(group));
351 assert_eq!(t.table_count(), 1);
352 // Text and code share the `texts` bucket.
353 let code = t.add(
354 None,
355 None,
356 TreeKind::Code {
357 text: "x".into(),
358 orig: None,
359 language: None,
360 formatting: None,
361 hyperlink: None,
362 },
363 );
364 assert_eq!(t.bucket_index(code), 3, "title, a, b precede it in `texts`");
365 assert_eq!(t.bucket_index(group), 0);
366 assert_eq!(t.body, vec![title, code]);
367 }
368
369 /// `append` renumbers a fragment built on its own (a slide converted in
370 /// parallel) so the merged tree reads as if built in one pass: parents,
371 /// children, comment back-refs and caption refs all shift together.
372 #[test]
373 fn append_renumbers_a_fragment_into_creation_order() {
374 let mut whole = ItemTree::default();
375 let slide0 = whole.add(
376 None,
377 None,
378 TreeKind::Group {
379 label: "chapter".into(),
380 name: "slide-0".into(),
381 },
382 );
383 whole.add(Some(slide0), None, text("first"));
384
385 let mut frag = ItemTree::default();
386 let slide1 = frag.add(
387 None,
388 None,
389 TreeKind::Group {
390 label: "chapter".into(),
391 name: "slide-1".into(),
392 },
393 );
394 let cap = frag.add_with_prov(
395 Some(slide1),
396 None,
397 TreeKind::Text {
398 label: "caption".into(),
399 text: "Title".into(),
400 orig: None,
401 formatting: None,
402 hyperlink: None,
403 level: None,
404 list: None,
405 },
406 TreeProv {
407 page_no: 2,
408 bbox: [1.0, 2.0, 3.0, 4.0],
409 bottom_left: true,
410 charspan: [0, 5],
411 },
412 );
413 let pic = frag.add(
414 Some(slide1),
415 None,
416 TreeKind::Picture {
417 captions: vec![cap],
418 image: None,
419 classification: Some("bar_chart".into()),
420 chart: None,
421 dpi: None,
422 },
423 );
424 let note = frag.add(
425 None,
426 Some(ContentLayer::Notes),
427 TreeKind::Group {
428 label: "comment_section".into(),
429 name: "comment-slide2-1".into(),
430 },
431 );
432 frag.items[pic].comments.push(note);
433
434 whole.append(frag);
435 assert_eq!(whole.body, vec![slide0, 2, 5]);
436 assert_eq!(whole.items[2].children, vec![3, 4]);
437 assert_eq!(whole.items[3].parent, Some(2));
438 assert_eq!(whole.items[3].prov.as_ref().map(|p| p.page_no), Some(2));
439 assert!(
440 matches!(&whole.items[4].kind, TreeKind::Picture { captions, .. } if captions == &[3])
441 );
442 assert_eq!(whole.items[4].comments, vec![5]);
443 assert_eq!(whole.items[5].parent, None);
444 assert_eq!(
445 whole.bucket_index(4),
446 0,
447 "the fragment's picture is #/pictures/0"
448 );
449 assert_eq!(
450 whole.bucket_index(5),
451 2,
452 "slide-0, slide-1 precede it in `groups`"
453 );
454 }
455}