Skip to main content

pdf_engine/
docling.rs

1//! The block model as a Docling `DoclingDocument` (#629).
2//!
3//! A pipeline built on Docling reads its documents through `docling-core`'s
4//! `DoclingDocument`: it walks `body`, filters on `label`, cites `prov` and
5//! asks the table for its `grid`. This writes that document from our blocks,
6//! so such a pipeline can swap the converter and keep every line downstream of
7//! it. `crates/pdf-python/tests/test_docling_document.py` loads what this
8//! writes with docling-core's own model and checks it comes back unchanged.
9//!
10//! THE SCHEMA IS PINNED: `DoclingDocument` version [`SCHEMA_VERSION`], as
11//! `docling-core` [`DOCLING_CORE_VERSION`] writes it (MIT, the Docling project).
12//! The test above installs exactly that version, so a schema that moves is a
13//! decision somebody makes here and not a surprise in a customer's pipeline.
14//!
15//! WHAT MAPS, block kind by block kind:
16//!
17//! - a paragraph is a `text` item;
18//! - a heading is a `section_header` with the block's level (1 to 3);
19//! - a list item is a `list_item` inside a `list` group, `enumerated` where the
20//!   document numbered it and with the marker written as `N.` or `-`. A nested
21//!   item opens a group under the item above it, which is where Docling puts a
22//!   sub-list;
23//! - a run of table cells is one `table`, its cells with their spans and
24//!   `column_header` from the header row, and the `grid` Docling derives from
25//!   them written out as Docling would derive it;
26//! - a running head and a running footer are `page_header` and `page_footer`
27//!   under `furniture`, in the `furniture` content layer, so every Docling
28//!   export that leaves the furniture out leaves them out too;
29//! - the half of a paragraph a page break cut off is not an item of its own: it
30//!   is joined onto the paragraph it continues, which then carries one `prov`
31//!   entry per page, each with the `charspan` of its own half.
32//!
33//! PROVENANCE: `page_no` is 1-BASED, because Docling's is -- the one place this
34//! output disagrees with `Block::page` and with the page markers of
35//! `crate::markdown`, which count from zero. The box is `Block::bbox` as it is:
36//! the page as it is displayed, `/Rotate` applied, in points from the bottom
37//! left corner of the crop box with y up (`BOTTOMLEFT`). `pages` gives each
38//! page's size in that same frame, which is what makes the box relative to a
39//! page and not to a sheet of paper nobody sees.
40//!
41//! WHAT HAS NO MAPPING, stated so nobody looks for it:
42//!
43//! - `Block::confidence`: `DoclingDocument` has no per-item confidence;
44//! - `Block::spans`: Docling items carry no runs, fonts or sizes. The caller that
45//!   needs them reads `crate::to_json`;
46//! - the document's own list marker: `c)` and `iv.` come out as `3.` and `4.`,
47//!   because the block keeps the number and not the glyphs;
48//! - `title`, `caption`, `footnote`, `picture`, `formula`, `code`, key-value
49//!   and form items: the block model has no such kind, so nothing is written
50//!   under those labels, and `pictures`, `key_value_items` and `form_items`
51//!   are always empty;
52//! - `origin`: the serializer does not see the file, so it is `null`.
53//!
54//! DETERMINISTIC, like the other two serializers: keys in a fixed order, every
55//! number through `crate::json::number`, two spaces per level.
56
57// Copyright (c) 2026 Innovation Trigger B.V.
58//
59// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
60// the PDFluent Commercial Licence. See the LICENSE file in this repository --
61// that file travels with the copy you received, which a URL does not.
62
63use crate::blocks::{Block, BlockKind};
64use crate::json::{number, string};
65#[cfg(test)]
66use crate::table_trace::TableOrigin;
67
68/// The `DoclingDocument` schema version this writes.
69pub const SCHEMA_VERSION: &str = "1.10.0";
70
71/// The `docling-core` release whose model defines [`SCHEMA_VERSION`], and the
72/// one the round-trip test loads the output with.
73pub const DOCLING_CORE_VERSION: &str = "2.98.1";
74
75/// One page of the document as `DoclingDocument.pages` lists it.
76#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct DoclingPage {
78    /// 0-based page index, the same number as `Block::page`.
79    pub page: usize,
80    /// Width in points.
81    pub width: f64,
82    /// Height in points.
83    pub height: f64,
84}
85
86/// A JSON value, written in the order it was built.
87enum Value {
88    Null,
89    Bool(bool),
90    Int(usize),
91    Number(f64),
92    Str(String),
93    Array(Vec<Value>),
94    Object(Vec<(&'static str, Value)>),
95    /// An object whose keys are data (`pages` is keyed by page number).
96    Map(Vec<(String, Value)>),
97}
98
99impl Value {
100    fn text(text: &str) -> Value {
101        Value::Str(text.to_string())
102    }
103
104    fn write(&self, out: &mut String, depth: usize) {
105        match self {
106            Value::Null => out.push_str("null"),
107            Value::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
108            Value::Int(n) => out.push_str(&n.to_string()),
109            Value::Number(n) => out.push_str(&number(*n)),
110            Value::Str(s) => out.push_str(&string(s)),
111            Value::Array(items) if items.is_empty() => out.push_str("[]"),
112            Value::Array(items) => {
113                out.push_str("[\n");
114                for (i, item) in items.iter().enumerate() {
115                    if i > 0 {
116                        out.push_str(",\n");
117                    }
118                    indent(out, depth + 1);
119                    item.write(out, depth + 1);
120                }
121                out.push('\n');
122                indent(out, depth);
123                out.push(']');
124            }
125            Value::Object(fields) => {
126                let fields: Vec<(&str, &Value)> = fields.iter().map(|(k, v)| (*k, v)).collect();
127                write_object(out, &fields, depth);
128            }
129            Value::Map(fields) => {
130                let fields: Vec<(&str, &Value)> =
131                    fields.iter().map(|(k, v)| (k.as_str(), v)).collect();
132                write_object(out, &fields, depth);
133            }
134        }
135    }
136}
137
138fn indent(out: &mut String, depth: usize) {
139    for _ in 0..depth {
140        out.push_str("  ");
141    }
142}
143
144fn write_object(out: &mut String, fields: &[(&str, &Value)], depth: usize) {
145    if fields.is_empty() {
146        out.push_str("{}");
147        return;
148    }
149    out.push_str("{\n");
150    for (i, (key, value)) in fields.iter().enumerate() {
151        if i > 0 {
152            out.push_str(",\n");
153        }
154        indent(out, depth + 1);
155        out.push_str(&string(key));
156        out.push_str(": ");
157        value.write(out, depth + 1);
158    }
159    out.push('\n');
160    indent(out, depth);
161    out.push('}');
162}
163
164/// `{"cref": "#/texts/3"}`, Docling's reference to another node.
165fn cref(target: &str) -> Value {
166    Value::Object(vec![("cref", Value::text(target))])
167}
168
169/// A `BoundingBox` over `[x0, y0, x1, y1]`, y up.
170fn bounding_box(bbox: &[f64; 4]) -> Value {
171    Value::Object(vec![
172        ("l", Value::Number(bbox[0])),
173        ("t", Value::Number(bbox[3])),
174        ("r", Value::Number(bbox[2])),
175        ("b", Value::Number(bbox[1])),
176        ("coord_origin", Value::text("BOTTOMLEFT")),
177    ])
178}
179
180/// One `ProvenanceItem`.
181fn provenance(page: usize, bbox: &[f64; 4], from: usize, to: usize) -> Value {
182    Value::Object(vec![
183        ("page_no", Value::Int(page + 1)),
184        ("bbox", bounding_box(bbox)),
185        (
186            "charspan",
187            Value::Array(vec![Value::Int(from), Value::Int(to)]),
188        ),
189    ])
190}
191
192/// Where a node hangs: under one of the two roots or under another node.
193#[derive(Clone)]
194struct Parent(String);
195
196/// A text item before it is written: kept open so the half of a paragraph on
197/// the next page can still be joined onto it.
198struct TextNode {
199    parent: Parent,
200    children: Vec<String>,
201    furniture: bool,
202    label: &'static str,
203    text: String,
204    /// `(page, bbox, charspan start, charspan end)`, one per piece.
205    prov: Vec<(usize, [f64; 4], usize, usize)>,
206    /// The fields of the label alone, after `hyperlink`.
207    extra: Vec<(&'static str, Value)>,
208}
209
210struct GroupNode {
211    parent: Parent,
212    children: Vec<String>,
213}
214
215struct TableNode {
216    parent: Parent,
217    page: usize,
218    bbox: [f64; 4],
219    cells: Vec<Cell>,
220    rows: usize,
221    columns: usize,
222}
223
224#[derive(Clone)]
225struct Cell {
226    bbox: [f64; 4],
227    row: usize,
228    column: usize,
229    row_span: usize,
230    column_span: usize,
231    header: bool,
232    text: String,
233}
234
235impl Cell {
236    fn value(&self) -> Value {
237        Value::Object(vec![
238            ("bbox", bounding_box(&self.bbox)),
239            ("row_span", Value::Int(self.row_span)),
240            ("col_span", Value::Int(self.column_span)),
241            ("start_row_offset_idx", Value::Int(self.row)),
242            ("end_row_offset_idx", Value::Int(self.row + self.row_span)),
243            ("start_col_offset_idx", Value::Int(self.column)),
244            (
245                "end_col_offset_idx",
246                Value::Int(self.column + self.column_span),
247            ),
248            ("text", Value::text(&self.text)),
249            ("column_header", Value::Bool(self.header)),
250            ("row_header", Value::Bool(false)),
251            ("row_section", Value::Bool(false)),
252            ("fillable", Value::Bool(false)),
253        ])
254    }
255
256    /// The empty cell Docling's `grid` puts where no cell reaches.
257    fn empty_value(row: usize, column: usize) -> Value {
258        Value::Object(vec![
259            ("bbox", Value::Null),
260            ("row_span", Value::Int(1)),
261            ("col_span", Value::Int(1)),
262            ("start_row_offset_idx", Value::Int(row)),
263            ("end_row_offset_idx", Value::Int(row + 1)),
264            ("start_col_offset_idx", Value::Int(column)),
265            ("end_col_offset_idx", Value::Int(column + 1)),
266            ("text", Value::text("")),
267            ("column_header", Value::Bool(false)),
268            ("row_header", Value::Bool(false)),
269            ("row_section", Value::Bool(false)),
270            ("fillable", Value::Bool(false)),
271        ])
272    }
273}
274
275impl TableNode {
276    /// `TableData.grid` as docling-core computes it: every position an empty
277    /// cell, then every cell written over the positions it covers, the later
278    /// cell winning where two overlap.
279    fn grid(&self) -> Value {
280        let mut grid: Vec<Vec<Option<usize>>> = vec![vec![None; self.columns]; self.rows];
281        for (i, cell) in self.cells.iter().enumerate() {
282            for row in grid
283                .iter_mut()
284                .take((cell.row + cell.row_span).min(self.rows))
285                .skip(cell.row.min(self.rows))
286            {
287                for slot in row
288                    .iter_mut()
289                    .take((cell.column + cell.column_span).min(self.columns))
290                    .skip(cell.column.min(self.columns))
291                {
292                    *slot = Some(i);
293                }
294            }
295        }
296        Value::Array(
297            grid.iter()
298                .enumerate()
299                .map(|(r, row)| {
300                    Value::Array(
301                        row.iter()
302                            .enumerate()
303                            .map(|(c, slot)| match slot {
304                                Some(i) => self.cells[*i].value(),
305                                None => Cell::empty_value(r, c),
306                            })
307                            .collect(),
308                    )
309                })
310                .collect(),
311        )
312    }
313}
314
315/// The union of two boxes.
316fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] {
317    [
318        a[0].min(b[0]),
319        a[1].min(b[1]),
320        a[2].max(b[2]),
321        a[3].max(b[3]),
322    ]
323}
324
325/// Everything the document holds, in the order Docling numbers it.
326#[derive(Default)]
327struct Tree {
328    body: Vec<String>,
329    furniture: Vec<String>,
330    texts: Vec<TextNode>,
331    groups: Vec<GroupNode>,
332    tables: Vec<TableNode>,
333}
334
335/// One open list: its group, its level, and its last item so far.
336struct OpenList {
337    group: usize,
338    level: u8,
339    enumerated: bool,
340    last_item: Option<usize>,
341}
342
343impl Tree {
344    fn attach(&mut self, parent: &Parent, child: String) {
345        match parent.0.as_str() {
346            "#/body" => self.body.push(child),
347            "#/furniture" => self.furniture.push(child),
348            other => {
349                if let Some(i) = other.strip_prefix("#/texts/") {
350                    if let Ok(i) = i.parse::<usize>() {
351                        self.texts[i].children.push(child);
352                    }
353                } else if let Some(i) = other.strip_prefix("#/groups/") {
354                    if let Ok(i) = i.parse::<usize>() {
355                        self.groups[i].children.push(child);
356                    }
357                }
358            }
359        }
360    }
361
362    fn add_text(&mut self, node: TextNode) -> usize {
363        let index = self.texts.len();
364        let parent = node.parent.clone();
365        self.texts.push(node);
366        self.attach(&parent, format!("#/texts/{index}"));
367        index
368    }
369
370    fn add_group(&mut self, parent: Parent) -> usize {
371        let index = self.groups.len();
372        self.groups.push(GroupNode {
373            parent: parent.clone(),
374            children: Vec::new(),
375        });
376        self.attach(&parent, format!("#/groups/{index}"));
377        index
378    }
379
380    fn add_table(&mut self, node: TableNode) {
381        let index = self.tables.len();
382        let parent = node.parent.clone();
383        self.tables.push(node);
384        self.attach(&parent, format!("#/tables/{index}"));
385    }
386}
387
388fn body() -> Parent {
389    Parent("#/body".to_string())
390}
391
392fn text_node(block: &Block, label: &'static str, parent: Parent) -> TextNode {
393    TextNode {
394        parent,
395        children: Vec::new(),
396        furniture: false,
397        label,
398        text: block.text.clone(),
399        prov: vec![(block.page, block.bbox, 0, block.text.chars().count())],
400        extra: Vec::new(),
401    }
402}
403
404fn build(blocks: &[Block]) -> Tree {
405    let mut tree = Tree::default();
406    let mut lists: Vec<OpenList> = Vec::new();
407    // The paragraph the next block may continue, if the last thing written
408    // was one -- the same rule `crate::markdown` joins by.
409    let mut open_paragraph: Option<usize> = None;
410    let mut at = 0usize;
411    while at < blocks.len() {
412        if let Some(end) = crate::markdown::table_run(blocks, at) {
413            let run = &blocks[at..end];
414            at = end;
415            lists.clear();
416            open_paragraph = None;
417            let cells: Vec<Cell> = run
418                .iter()
419                .filter_map(|block| match block.kind {
420                    BlockKind::TableCell {
421                        row,
422                        column,
423                        row_span,
424                        column_span,
425                        header,
426                    } => Some(Cell {
427                        bbox: block.bbox,
428                        row: row as usize,
429                        column: column as usize,
430                        row_span: (row_span as usize).max(1),
431                        column_span: (column_span as usize).max(1),
432                        header,
433                        text: block.text.trim().to_string(),
434                    }),
435                    _ => None,
436                })
437                .collect();
438            let rows = cells.iter().map(|c| c.row + c.row_span).max().unwrap_or(0);
439            let columns = cells
440                .iter()
441                .map(|c| c.column + c.column_span)
442                .max()
443                .unwrap_or(0);
444            let bbox = run
445                .iter()
446                .skip(1)
447                .fold(run[0].bbox, |b, x| union(b, x.bbox));
448            tree.add_table(TableNode {
449                parent: body(),
450                page: run[0].page,
451                bbox,
452                cells,
453                rows,
454                columns,
455            });
456            continue;
457        }
458        let block = &blocks[at];
459        at += 1;
460        match block.kind {
461            BlockKind::Paragraph => {
462                lists.clear();
463                if let (true, Some(open)) = (block.continues_previous, open_paragraph) {
464                    let node = &mut tree.texts[open];
465                    let piece = block.text.trim();
466                    if crate::text::is_hyphen_wrap_candidate(&node.text, piece) {
467                        node.text.pop();
468                    } else {
469                        node.text.push(' ');
470                    }
471                    let from = node.text.chars().count();
472                    node.text.push_str(piece);
473                    let to = node.text.chars().count();
474                    node.prov.push((block.page, block.bbox, from, to));
475                    continue;
476                }
477                open_paragraph = Some(tree.add_text(text_node(block, "text", body())));
478            }
479            BlockKind::Heading { level } => {
480                lists.clear();
481                open_paragraph = None;
482                let mut node = text_node(block, "section_header", body());
483                node.extra
484                    .push(("level", Value::Int(level.clamp(1, 100) as usize)));
485                tree.add_text(node);
486            }
487            BlockKind::ListItem { level, ordinal } => {
488                open_paragraph = None;
489                let enumerated = ordinal.is_some();
490                while lists.last().is_some_and(|list| list.level > level) {
491                    lists.pop();
492                }
493                // A numbered list straight after a bulleted one at the same
494                // depth is a second list, not more of the first: the page
495                // changed its marker, and Docling's own export writes a
496                // group's items with one kind of marker.
497                if lists
498                    .last()
499                    .is_some_and(|list| list.level == level && list.enumerated != enumerated)
500                {
501                    lists.pop();
502                }
503                let group = match lists.last() {
504                    Some(list) if list.level == level => list.group,
505                    open => {
506                        // Deeper than the innermost open list: a sub-list,
507                        // which Docling hangs under the item it follows.
508                        let parent = match open {
509                            Some(OpenList {
510                                last_item: Some(item),
511                                ..
512                            }) => Parent(format!("#/texts/{item}")),
513                            Some(list) => Parent(format!("#/groups/{}", list.group)),
514                            None => body(),
515                        };
516                        let group = tree.add_group(parent);
517                        lists.push(OpenList {
518                            group,
519                            level,
520                            enumerated,
521                            last_item: None,
522                        });
523                        group
524                    }
525                };
526                let mut node = text_node(block, "list_item", Parent(format!("#/groups/{group}")));
527                node.extra.push(("enumerated", Value::Bool(enumerated)));
528                node.extra.push((
529                    "marker",
530                    Value::Str(ordinal.map_or_else(|| "-".to_string(), |n| format!("{n}."))),
531                ));
532                let item = tree.add_text(node);
533                if let Some(list) = lists.last_mut() {
534                    list.last_item = Some(item);
535                }
536            }
537            // DOCLING'S OWN LABEL: a caption is a text of its own, in the body
538            // where the page set it (#636).
539            BlockKind::Caption => {
540                open_paragraph = None;
541                lists.clear();
542                tree.add_text(text_node(block, "caption", body()));
543            }
544            BlockKind::Header | BlockKind::Footer => {
545                open_paragraph = None;
546                let label = if matches!(block.kind, BlockKind::Header) {
547                    "page_header"
548                } else {
549                    "page_footer"
550                };
551                let mut node = text_node(block, label, Parent("#/furniture".to_string()));
552                node.furniture = true;
553                tree.add_text(node);
554            }
555            // Unreachable: `table_run` takes every cell above.
556            BlockKind::TableCell { .. } => {}
557            // No picture node: Docling's tree carries texts, tables and
558            // groups, and a figure is none of those. It still breaks the
559            // open runs the way any block boundary does.
560            BlockKind::Figure => {
561                open_paragraph = None;
562                lists.clear();
563            }
564        }
565    }
566    tree
567}
568
569fn root(name: &'static str, children: &[String]) -> Value {
570    Value::Object(vec![
571        ("self_ref", Value::Str(format!("#/{name}"))),
572        ("parent", Value::Null),
573        (
574            "children",
575            Value::Array(children.iter().map(|c| cref(c)).collect()),
576        ),
577        ("content_layer", Value::text(name)),
578        ("meta", Value::Null),
579        ("name", Value::text("_root_")),
580        ("label", Value::text("unspecified")),
581    ])
582}
583
584/// The blocks as a `DoclingDocument`, pinned to [`SCHEMA_VERSION`].
585///
586/// Preview: output quality is still improving and the API may change in a minor release.
587///
588/// `name` is `DoclingDocument.name`, which Docling fills with the file's stem.
589/// `pages` is every page the blocks were taken from, with its size; a block on
590/// a page missing from it is still written, with nothing in `pages` to size
591/// it, which Docling accepts.
592///
593/// ```
594/// use pdf_engine::{to_docling_json, Block, BlockKind, DoclingPage};
595///
596/// let blocks = vec![Block::new(
597///     BlockKind::Heading { level: 1 },
598///     0,
599///     [72.0, 700.0, 200.0, 712.0],
600///     "Scope".to_string(),
601///     0,
602/// )];
603/// let page = DoclingPage { page: 0, width: 612.0, height: 792.0 };
604/// let json = to_docling_json("report", &[page], &blocks);
605/// assert!(json.contains("\"label\": \"section_header\""));
606/// ```
607pub fn to_docling_json(name: &str, pages: &[DoclingPage], blocks: &[Block]) -> String {
608    let tree = build(blocks);
609    let texts: Vec<Value> = tree
610        .texts
611        .into_iter()
612        .enumerate()
613        .map(|(i, node)| {
614            let mut fields = vec![
615                ("self_ref", Value::Str(format!("#/texts/{i}"))),
616                ("parent", cref(&node.parent.0)),
617                (
618                    "children",
619                    Value::Array(node.children.iter().map(|c| cref(c)).collect()),
620                ),
621                (
622                    "content_layer",
623                    Value::text(if node.furniture { "furniture" } else { "body" }),
624                ),
625                ("meta", Value::Null),
626                ("label", Value::text(node.label)),
627                (
628                    "prov",
629                    Value::Array(
630                        node.prov
631                            .iter()
632                            .map(|(page, bbox, from, to)| provenance(*page, bbox, *from, *to))
633                            .collect(),
634                    ),
635                ),
636                ("orig", Value::text(&node.text)),
637                ("text", Value::text(&node.text)),
638                ("formatting", Value::Null),
639                ("hyperlink", Value::Null),
640            ];
641            fields.extend(node.extra);
642            Value::Object(fields)
643        })
644        .collect();
645    let groups: Vec<Value> = tree
646        .groups
647        .iter()
648        .enumerate()
649        .map(|(i, node)| {
650            Value::Object(vec![
651                ("self_ref", Value::Str(format!("#/groups/{i}"))),
652                ("parent", cref(&node.parent.0)),
653                (
654                    "children",
655                    Value::Array(node.children.iter().map(|c| cref(c)).collect()),
656                ),
657                ("content_layer", Value::text("body")),
658                ("meta", Value::Null),
659                ("name", Value::text("list")),
660                ("label", Value::text("list")),
661            ])
662        })
663        .collect();
664    let tables: Vec<Value> = tree
665        .tables
666        .iter()
667        .enumerate()
668        .map(|(i, node)| {
669            Value::Object(vec![
670                ("self_ref", Value::Str(format!("#/tables/{i}"))),
671                ("parent", cref(&node.parent.0)),
672                ("children", Value::Array(Vec::new())),
673                ("content_layer", Value::text("body")),
674                ("meta", Value::Null),
675                ("label", Value::text("table")),
676                (
677                    "prov",
678                    Value::Array(vec![provenance(node.page, &node.bbox, 0, 0)]),
679                ),
680                ("captions", Value::Array(Vec::new())),
681                ("references", Value::Array(Vec::new())),
682                ("footnotes", Value::Array(Vec::new())),
683                ("image", Value::Null),
684                (
685                    "data",
686                    Value::Object(vec![
687                        (
688                            "table_cells",
689                            Value::Array(node.cells.iter().map(Cell::value).collect()),
690                        ),
691                        ("num_rows", Value::Int(node.rows)),
692                        ("num_cols", Value::Int(node.columns)),
693                        ("orientation", Value::text("rot_0")),
694                        ("grid", node.grid()),
695                    ]),
696                ),
697                ("annotations", Value::Array(Vec::new())),
698            ])
699        })
700        .collect();
701    let mut sorted: Vec<DoclingPage> = pages.to_vec();
702    sorted.sort_by_key(|p| p.page);
703    sorted.dedup_by_key(|p| p.page);
704    let pages = Value::Map(
705        sorted
706            .iter()
707            .map(|p| {
708                (
709                    (p.page + 1).to_string(),
710                    Value::Object(vec![
711                        (
712                            "size",
713                            Value::Object(vec![
714                                ("width", Value::Number(p.width)),
715                                ("height", Value::Number(p.height)),
716                            ]),
717                        ),
718                        ("image", Value::Null),
719                        ("page_no", Value::Int(p.page + 1)),
720                    ]),
721                )
722            })
723            .collect(),
724    );
725    let document = Value::Object(vec![
726        ("schema_name", Value::text("DoclingDocument")),
727        ("version", Value::text(SCHEMA_VERSION)),
728        ("name", Value::text(name)),
729        ("origin", Value::Null),
730        ("furniture", root("furniture", &tree.furniture)),
731        ("body", root("body", &tree.body)),
732        ("groups", Value::Array(groups)),
733        ("texts", Value::Array(texts)),
734        ("pictures", Value::Array(Vec::new())),
735        ("tables", Value::Array(tables)),
736        ("key_value_items", Value::Array(Vec::new())),
737        ("form_items", Value::Array(Vec::new())),
738        ("pages", pages),
739    ]);
740    let mut out = String::new();
741    document.write(&mut out, 0);
742    out.push('\n');
743    out
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    fn block(kind: BlockKind, text: &str, page: usize) -> Block {
751        Block {
752            kind,
753            page,
754            bbox: [72.0, 700.0, 200.0, 712.0],
755            text: text.to_string(),
756            spans: Vec::new(),
757            index: 0,
758            continues_previous: false,
759            confidence: None,
760            grid: None,
761            table_origin: TableOrigin::Other,
762            trace_learned: None,
763        }
764    }
765
766    /// A caption carries Docling's own label (#636).
767    #[test]
768    fn a_caption_carries_the_caption_label() {
769        let json = to_docling_json("d", &[], &[block(BlockKind::Caption, "Figure 1.", 0)]);
770        assert!(
771            json.contains("\"label\":\"caption\"") || json.contains("\"label\": \"caption\""),
772            "{json}"
773        );
774    }
775
776    fn cell(row: u16, column: u16, column_span: u16, header: bool, text: &str) -> Block {
777        block(
778            BlockKind::TableCell {
779                row,
780                column,
781                row_span: 1,
782                column_span,
783                header,
784            },
785            text,
786            0,
787        )
788    }
789
790    fn page(page: usize) -> DoclingPage {
791        DoclingPage {
792            page,
793            width: 612.0,
794            height: 792.0,
795        }
796    }
797
798    #[test]
799    fn nothing_in_is_an_empty_document_with_its_pages() {
800        let json = to_docling_json("empty", &[page(0)], &[]);
801        assert!(
802            json.contains("\"schema_name\": \"DoclingDocument\""),
803            "{json}"
804        );
805        assert!(json.contains("\"version\": \"1.10.0\""), "{json}");
806        assert!(json.contains("\"texts\": []"), "{json}");
807        assert!(json.contains("\"1\": {"), "{json}");
808        assert!(json.contains("\"page_no\": 1"), "{json}");
809    }
810
811    #[test]
812    fn a_heading_is_a_section_header_with_its_level_and_a_one_based_page() {
813        let json = to_docling_json(
814            "d",
815            &[page(2)],
816            &[block(BlockKind::Heading { level: 2 }, "Terms", 2)],
817        );
818        assert!(json.contains("\"label\": \"section_header\""), "{json}");
819        assert!(json.contains("\"level\": 2"), "{json}");
820        assert!(json.contains("\"page_no\": 3"), "{json}");
821        assert!(json.contains("\"coord_origin\": \"BOTTOMLEFT\""), "{json}");
822        assert!(json.contains("\"t\": 712"), "{json}");
823        assert!(json.contains("\"b\": 700"), "{json}");
824    }
825
826    #[test]
827    fn the_running_head_hangs_under_furniture_and_not_under_body() {
828        let json = to_docling_json(
829            "d",
830            &[page(0)],
831            &[
832                block(BlockKind::Paragraph, "Body.", 0),
833                block(BlockKind::Header, "Annual report", 0),
834            ],
835        );
836        let furniture = json.find("\"furniture\": {").unwrap();
837        let body = json.find("\"body\": {").unwrap();
838        let head_ref = json[furniture..body].find("#/texts/1");
839        assert!(head_ref.is_some(), "{json}");
840        assert!(json.contains("\"label\": \"page_header\""), "{json}");
841        assert!(json.contains("\"content_layer\": \"furniture\""), "{json}");
842    }
843
844    #[test]
845    fn a_nested_item_opens_a_group_under_the_item_above_it() {
846        let item = |level, ordinal, text| block(BlockKind::ListItem { level, ordinal }, text, 0);
847        let tree = build(&[
848            item(0, Some(1), "one"),
849            item(1, None, "one-a"),
850            item(0, Some(2), "two"),
851        ]);
852        assert_eq!(tree.groups.len(), 2);
853        assert_eq!(tree.body, vec!["#/groups/0"]);
854        assert_eq!(tree.groups[0].children, vec!["#/texts/0", "#/texts/2"]);
855        assert_eq!(tree.texts[0].children, vec!["#/groups/1"]);
856        assert_eq!(tree.groups[1].children, vec!["#/texts/1"]);
857        let json = to_docling_json("d", &[page(0)], &[item(0, Some(3), "x")]);
858        let split = build(&[item(0, None, "bullet"), item(0, Some(1), "numbered")]);
859        assert_eq!(split.body, vec!["#/groups/0", "#/groups/1"]);
860        assert!(json.contains("\"enumerated\": true"), "{json}");
861        assert!(json.contains("\"marker\": \"3.\""), "{json}");
862    }
863
864    #[test]
865    fn a_paragraph_the_break_cut_in_two_is_one_item_with_two_provenances() {
866        let mut second = block(BlockKind::Paragraph, "on the next page.", 1);
867        second.continues_previous = true;
868        let tree = build(&[block(BlockKind::Paragraph, "A sentence carried", 0), second]);
869        assert_eq!(tree.texts.len(), 1);
870        let node = &tree.texts[0];
871        assert_eq!(node.text, "A sentence carried on the next page.");
872        assert_eq!(node.prov.len(), 2);
873        assert_eq!(
874            (node.prov[1].0, node.prov[1].2, node.prov[1].3),
875            (1, 19, 36)
876        );
877    }
878
879    #[test]
880    fn a_table_carries_its_spans_and_the_grid_docling_derives() {
881        let tree = build(&[
882            cell(0, 0, 2, true, "Band"),
883            cell(1, 0, 1, false, "A"),
884            cell(1, 1, 1, false, "1"),
885        ]);
886        assert_eq!(tree.tables.len(), 1);
887        let table = &tree.tables[0];
888        assert_eq!((table.rows, table.columns), (2, 2));
889        let mut grid = String::new();
890        table.grid().write(&mut grid, 0);
891        // The spanning header fills both positions of the first row.
892        assert_eq!(grid.matches("\"text\": \"Band\"").count(), 2, "{grid}");
893    }
894
895    #[test]
896    fn a_hole_in_the_grid_is_an_empty_cell_without_a_box() {
897        let tree = build(&[cell(0, 0, 1, false, "a"), cell(1, 1, 1, false, "b")]);
898        let mut grid = String::new();
899        tree.tables[0].grid().write(&mut grid, 0);
900        assert_eq!(grid.matches("\"bbox\": null").count(), 2, "{grid}");
901    }
902
903    #[test]
904    fn the_same_blocks_write_the_same_bytes() {
905        let blocks = vec![
906            block(BlockKind::Heading { level: 1 }, "Scope", 0),
907            block(BlockKind::Paragraph, "Body.", 0),
908        ];
909        assert_eq!(
910            to_docling_json("d", &[page(0)], &blocks),
911            to_docling_json("d", &[page(0)], &blocks)
912        );
913    }
914}