1use crate::blocks::{Block, BlockKind};
64use crate::json::{number, string};
65#[cfg(test)]
66use crate::table_trace::TableOrigin;
67
68pub const SCHEMA_VERSION: &str = "1.10.0";
70
71pub const DOCLING_CORE_VERSION: &str = "2.98.1";
74
75#[derive(Debug, Clone, Copy, PartialEq)]
77pub struct DoclingPage {
78 pub page: usize,
80 pub width: f64,
82 pub height: f64,
84}
85
86enum 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 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
164fn cref(target: &str) -> Value {
166 Value::Object(vec![("cref", Value::text(target))])
167}
168
169fn 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
180fn 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#[derive(Clone)]
194struct Parent(String);
195
196struct TextNode {
199 parent: Parent,
200 children: Vec<String>,
201 furniture: bool,
202 label: &'static str,
203 text: String,
204 prov: Vec<(usize, [f64; 4], usize, usize)>,
206 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 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 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
315fn 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#[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
335struct 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 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 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 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 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 BlockKind::TableCell { .. } => {}
557 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
584pub 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 #[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 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}