use serde_json::{json, Map, Value};
use crate::document::{CaptionParent, ContentLayer, DoclingDocument, Node, Table};
const SCHEMA_VERSION: &str = "1.10.0";
const CODE_LANGUAGES: &[&str] = &[
"Ada",
"Awk",
"Bash",
"bc",
"C",
"C#",
"C++",
"CMake",
"COBOL",
"CSS",
"Ceylon",
"Clojure",
"Crystal",
"Cuda",
"Cython",
"D",
"Dart",
"dc",
"Dockerfile",
"DocLang",
"Elixir",
"Erlang",
"FORTRAN",
"Forth",
"Go",
"HTML",
"Haskell",
"Haxe",
"Java",
"JavaScript",
"JSON",
"Julia",
"Kotlin",
"Latex",
"Lisp",
"Lua",
"Matlab",
"MoonScript",
"Nim",
"OCaml",
"ObjectiveC",
"Octave",
"PHP",
"Pascal",
"Perl",
"Prolog",
"Python",
"Racket",
"Ruby",
"Rust",
"SML",
"SQL",
"Scala",
"Scheme",
"Swift",
"Tikz",
"TypeScript",
"VisualBasic",
"XML",
"YAML",
];
pub fn code_language_label(lang: &str) -> &'static str {
code_language(Some(lang))
}
pub(crate) fn code_language(lang: Option<&str>) -> &'static str {
match lang {
Some(l) => CODE_LANGUAGES
.iter()
.find(|c| c.eq_ignore_ascii_case(l))
.copied()
.unwrap_or("unknown"),
None => "unknown",
}
}
#[derive(Clone, Copy)]
struct ExactProv {
page_no: usize,
bbox: [f64; 4],
bottom_left: bool,
charspan: [usize; 2],
}
impl From<&crate::tree::TreeProv> for ExactProv {
fn from(p: &crate::tree::TreeProv) -> Self {
ExactProv {
page_no: p.page_no,
bbox: p.bbox,
bottom_left: p.bottom_left,
charspan: p.charspan,
}
}
}
type PageSizes = std::collections::HashMap<usize, (f64, f64)>;
fn page_sizes(pages: &[(usize, f64, f64)]) -> PageSizes {
let mut sizes = PageSizes::with_capacity(pages.len());
for &(p, w, h) in pages {
sizes.entry(p).or_insert((w, h));
}
sizes
}
fn clamp_boxes_to_pages(b: &mut Builder) {
if b.pages.is_empty() {
return;
}
let sizes = page_sizes(&b.pages);
for item in &mut b.texts {
match item {
Item::Text(t) => t.prov.clamp(&sizes),
Item::Json(v) => clamp_item(v, &sizes),
}
}
for item in b
.pictures
.iter_mut()
.chain(b.tables.iter_mut())
.chain(b.key_value_items.iter_mut())
{
clamp_item(item, &sizes);
}
}
fn clamp_item(item: &mut Value, sizes: &PageSizes) {
let size = |page_no: &Value| -> Option<(f64, f64)> {
let n = page_no.as_u64()? as usize;
sizes.get(&n).copied()
};
let clamp_bbox = |bbox: &mut Value, (w, h): (f64, f64)| {
for (key, hi) in [("l", w), ("r", w), ("t", h), ("b", h)] {
if let Some(v) = bbox.get(key).and_then(Value::as_f64) {
bbox[key] = json!(round2(v.clamp(0.0, hi.max(0.0))));
}
}
};
let mut table_page: Option<Option<(f64, f64)>> = None;
if let Some(provs) = item.get_mut("prov").and_then(Value::as_array_mut) {
let mut page_nos: Vec<u64> = Vec::new();
for prov in provs.iter_mut() {
if let Some(n) = prov.get("page_no").and_then(Value::as_u64) {
page_nos.push(n);
}
let Some(sz) = prov.get("page_no").and_then(size) else {
continue;
};
if let Some(bbox) = prov.get_mut("bbox") {
clamp_bbox(bbox, sz);
}
}
page_nos.sort_unstable();
page_nos.dedup();
if let [only] = page_nos[..] {
table_page = Some(size(&json!(only)));
}
}
if let (Some(Some(sz)), Some(data)) = (table_page, item.get_mut("data")) {
for key in ["table_cells", "grid"] {
let Some(rows) = data.get_mut(key).and_then(Value::as_array_mut) else {
continue;
};
for entry in rows.iter_mut() {
let cells: Vec<&mut Value> = match entry {
Value::Array(row) => row.iter_mut().collect(),
other => vec![other],
};
for cell in cells {
if let Some(bbox) = cell.get_mut("bbox").filter(|b| b.is_object()) {
clamp_bbox(bbox, sz);
}
}
}
}
}
}
fn formatting_json(f: &crate::tree::Formatting) -> Value {
json!({
"bold": f.bold,
"italic": f.italic,
"underline": f.underline,
"strikethrough": f.strikethrough,
"script": match f.script {
crate::Script::Baseline => "baseline",
crate::Script::Sub => "sub",
crate::Script::Super => "super",
},
})
}
fn track_json(t: &crate::tree::TreeTrack) -> Value {
let mut m = serde_json::Map::new();
m.insert("kind".into(), json!("track"));
m.insert("start_time".into(), json!(t.start_time));
m.insert("end_time".into(), json!(t.end_time));
if let Some(id) = &t.identifier {
m.insert("identifier".into(), json!(id));
}
if let Some(v) = &t.voice {
m.insert("voice".into(), json!(v));
}
Value::Object(m)
}
fn image_ref_json(img: &crate::PictureImage) -> Value {
let (mimetype, uri) = crate::pixel_digest::docling_data_uri(img);
json!({
"mimetype": mimetype,
"dpi": img.dpi,
"size": { "width": img.width as f64, "height": img.height as f64 },
"uri": uri,
})
}
pub fn to_json(doc: &DoclingDocument) -> Value {
build_json(doc, false)
}
pub(crate) fn to_json_with_notes(doc: &DoclingDocument) -> Value {
build_json(doc, true)
}
fn build_parts(doc: &DoclingDocument, note_keys: bool) -> Parts {
let mut b = Builder {
note_keys,
..Builder::default()
};
let body = match &doc.tree {
Some(tree) => {
for n in &doc.nodes {
if let Node::PageInfo {
page_no,
width,
height,
} = n
{
if *page_no > 0 {
b.pages.push((*page_no, *width as f64, *height as f64));
}
}
}
let mut tree = tree.clone();
tree.wrap_misplaced_list_items();
b.write_tree(&tree).into_iter().map(Child::json).collect()
}
None => b.walk_into(&doc.nodes, "#/body"),
};
b.link_comments();
clamp_boxes_to_pages(&mut b);
let pages = b
.pages
.iter()
.map(|(n, w, h)| {
let r2 = |v: f64| (v * 100.0).round() / 100.0;
let mut page = serde_json::Map::new();
page.insert("size".into(), json!({ "width": r2(*w), "height": r2(*h) }));
if let Some(img) = doc.page_images.get(n) {
page.insert("image".into(), image_ref_json(img));
}
page.insert("page_no".into(), json!(n));
(n.to_string(), Value::Object(page))
})
.collect::<serde_json::Map<String, Value>>();
let fields = (!b.field_regions.is_empty()).then_some((b.field_regions, b.field_items));
Parts {
name: doc.name.clone(),
origin: json!({
"mimetype": "text/plain",
"binary_hash": fnv1a(&doc.name),
"filename": doc.name,
}),
furniture: json!({
"self_ref": "#/furniture",
"children": [],
"content_layer": "furniture",
"name": "_root_",
"label": "unspecified",
}),
body,
groups: b.groups,
texts: b.texts,
pictures: b.pictures,
tables: b.tables,
key_value_items: b.key_value_items,
fields,
pages,
}
}
fn build_json(doc: &DoclingDocument, note_keys: bool) -> Value {
build_parts(doc, note_keys).into_value()
}
struct Parts {
name: String,
origin: Value,
furniture: Value,
body: Vec<Child>,
groups: Vec<Value>,
texts: Vec<Item>,
pictures: Vec<Value>,
tables: Vec<Value>,
key_value_items: Vec<Value>,
fields: Option<(Vec<Value>, Vec<Value>)>,
pages: serde_json::Map<String, Value>,
}
impl Parts {
fn into_value(self) -> Value {
let mut out = serde_json::Map::with_capacity(15);
out.insert("schema_name".into(), "DoclingDocument".into());
out.insert("version".into(), SCHEMA_VERSION.into());
out.insert("name".into(), Value::String(self.name));
out.insert("origin".into(), self.origin);
out.insert("furniture".into(), self.furniture);
out.insert(
"body".into(),
object([
("self_ref", "#/body".into()),
(
"children",
Value::Array(self.body.into_iter().map(Child::into_value).collect()),
),
("content_layer", "body".into()),
("name", "_root_".into()),
("label", "unspecified".into()),
]),
);
out.insert("groups".into(), Value::Array(self.groups));
out.insert(
"texts".into(),
Value::Array(
self.texts
.into_iter()
.enumerate()
.map(|(i, item)| item.into_value(i))
.collect(),
),
);
out.insert("pictures".into(), Value::Array(self.pictures));
out.insert("tables".into(), Value::Array(self.tables));
out.insert("key_value_items".into(), Value::Array(self.key_value_items));
out.insert("form_items".into(), Value::Array(Vec::new()));
if let Some((regions, items)) = self.fields {
out.insert("field_regions".into(), Value::Array(regions));
out.insert("field_items".into(), Value::Array(items));
}
out.insert("pages".into(), Value::Object(self.pages));
Value::Object(out)
}
}
impl serde::Serialize for Parts {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(13 + 2 * usize::from(self.fields.is_some())))?;
m.serialize_entry("schema_name", "DoclingDocument")?;
m.serialize_entry("version", SCHEMA_VERSION)?;
m.serialize_entry("name", &self.name)?;
m.serialize_entry("origin", &self.origin)?;
m.serialize_entry("furniture", &self.furniture)?;
m.serialize_entry("body", &Body(&self.body))?;
m.serialize_entry("groups", &self.groups)?;
m.serialize_entry("texts", &Texts(&self.texts))?;
m.serialize_entry("pictures", &self.pictures)?;
m.serialize_entry("tables", &self.tables)?;
m.serialize_entry("key_value_items", &self.key_value_items)?;
m.serialize_entry("form_items", &[(); 0])?;
if let Some((regions, items)) = &self.fields {
m.serialize_entry("field_regions", regions)?;
m.serialize_entry("field_items", items)?;
}
m.serialize_entry("pages", &self.pages)?;
m.end()
}
}
struct Body<'a>(&'a [Child]);
impl serde::Serialize for Body<'_> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(5))?;
m.serialize_entry("self_ref", "#/body")?;
m.serialize_entry("children", self.0)?;
m.serialize_entry("content_layer", "body")?;
m.serialize_entry("name", "_root_")?;
m.serialize_entry("label", "unspecified")?;
m.end()
}
}
pub fn write_json<W: std::io::Write>(
doc: &DoclingDocument,
w: W,
pretty: bool,
) -> serde_json::Result<()> {
use serde::Serialize;
let parts = build_parts(doc, false);
if pretty {
parts.serialize(&mut serde_json::Serializer::pretty(w))
} else {
parts.serialize(&mut serde_json::Serializer::new(w))
}
}
fn classification_meta(classes: &[crate::PictureClass]) -> Value {
json!({
"classification": {
"predictions": classes.iter().map(|c| json!({
"confidence": c.confidence as f64,
"created_by": "DocumentPictureClassifier",
"class_name": c.class_name,
})).collect::<Vec<_>>(),
},
"annotations": [{
"kind": "classification",
"provenance": "DocumentPictureClassifier",
"predicted_classes": classes.iter().map(|c| json!({
"class_name": c.class_name,
"confidence": c.confidence as f64,
})).collect::<Vec<_>>(),
}],
})
}
fn description_meta(desc: &crate::PictureDescription) -> Value {
json!({
"description": {
"created_by": desc.provenance,
"text": desc.text,
},
"annotations": [{
"kind": "description",
"text": desc.text,
"provenance": desc.provenance,
}],
})
}
fn picture_meta(
classes: Option<&[crate::PictureClass]>,
desc: Option<&crate::PictureDescription>,
) -> Option<Value> {
let mut meta = Map::new();
let mut annotations = Vec::new();
if let Some(desc) = desc {
let mut m = description_meta(desc);
annotations.push(m["annotations"][0].take());
meta.insert("description".into(), m["description"].take());
}
if let Some(classes) = classes {
let mut m = classification_meta(classes);
annotations.insert(0, m["annotations"][0].take());
meta.insert("classification".into(), m["classification"].take());
}
if meta.is_empty() {
return None;
}
meta.insert("annotations".into(), Value::Array(annotations));
Some(Value::Object(meta))
}
#[allow(clippy::too_many_arguments)]
fn cell_value(
row_span: usize,
col_span: usize,
start_row: usize,
end_row: usize,
start_col: usize,
end_col: usize,
text: String,
column_header: bool,
row_header: bool,
row_section: bool,
bbox: Option<[f32; 4]>,
) -> Value {
let mut m = serde_json::Map::with_capacity(12);
m.insert("row_span".into(), row_span.into());
m.insert("col_span".into(), col_span.into());
m.insert("start_row_offset_idx".into(), start_row.into());
m.insert("end_row_offset_idx".into(), end_row.into());
m.insert("start_col_offset_idx".into(), start_col.into());
m.insert("end_col_offset_idx".into(), end_col.into());
m.insert("text".into(), Value::String(text));
m.insert("column_header".into(), column_header.into());
m.insert("row_header".into(), row_header.into());
m.insert("row_section".into(), row_section.into());
m.insert("fillable".into(), false.into());
if let Some(b) = bbox {
m.insert(
"bbox".into(),
json!({
"l": b[0], "t": b[1], "r": b[2], "b": b[3],
"coord_origin": "TOPLEFT",
}),
);
}
Value::Object(m)
}
fn table_data(t: &Table) -> Value {
table_data_with(t, false)
}
fn table_data_with(t: &Table, raw: bool) -> Value {
let cell_text = |s: &str| {
if raw {
s.to_string()
} else {
unescape_text(&crate::markdown::strip_hard_breaks(s))
}
};
let mut num_rows = t.rows.len();
let mut num_cols = t.rows.iter().map(Vec::len).max().unwrap_or(0);
let ragged = t.rows.iter().any(|r| r.len() != num_cols);
if let Some(first_class) = t.cells.as_ref().filter(|c| ragged && !c.is_empty()) {
for c in first_class {
num_rows = num_rows.max(c.start_row + c.row_span);
num_cols = num_cols.max(c.start_col + c.col_span);
}
}
let mut grid = Vec::with_capacity(num_rows);
let mut cells = Vec::new();
let mut slot: Vec<Option<usize>> = vec![None; num_rows * num_cols];
if let Some(first_class) = t.cells.as_ref().filter(|c| !c.is_empty()) {
for c in first_class {
let idx = cells.len();
cells.push(cell_value(
c.row_span,
c.col_span,
c.start_row,
c.start_row + c.row_span,
c.start_col,
c.start_col + c.col_span,
cell_text(&c.text),
c.column_header,
c.row_header,
c.row_section,
c.bbox,
));
for r in c.start_row..(c.start_row + c.row_span).min(num_rows) {
for k in c.start_col..(c.start_col + c.col_span).min(num_cols) {
slot[r * num_cols + k] = Some(idx);
}
}
}
for r in 0..num_rows {
let mut grid_row = Vec::with_capacity(num_cols);
for c in 0..num_cols {
grid_row.push(match slot[r * num_cols + c] {
Some(i) => cells[i].clone(),
None => cell_value(
1,
1,
r,
r + 1,
c,
c + 1,
String::new(),
false,
false,
false,
None,
),
});
}
grid.push(grid_row);
}
} else {
let s = t.structure.as_ref();
let flag = |grid: Option<&Vec<Vec<bool>>>, r: usize, c: usize| -> bool {
grid.and_then(|g| g.get(r))
.and_then(|row| row.get(c))
.copied()
.unwrap_or(false)
};
let anchor_of = |r: usize, c: usize| -> (usize, usize) {
let (mut r0, mut c0) = (r, c);
while c0 > 0 && flag(s.map(|s| &s.col_continuation), r, c0) {
c0 -= 1;
}
while r0 > 0 && flag(s.map(|s| &s.row_continuation), r0, c0) {
r0 -= 1;
}
(r0, c0)
};
let anchors: Vec<(usize, usize)> = (0..num_rows)
.flat_map(|r| (0..num_cols).map(move |c| (r, c)))
.map(|(r, c)| anchor_of(r, c))
.collect();
let mut extent: Vec<(usize, usize)> = (0..num_rows)
.flat_map(|r| (0..num_cols).map(move |c| (r, c)))
.collect();
for (i, &(ar, ac)) in anchors.iter().enumerate() {
let (r, c) = (i / num_cols.max(1), i % num_cols.max(1));
let e = &mut extent[ar * num_cols + ac];
e.0 = e.0.max(r);
e.1 = e.1.max(c);
}
for (r, row) in t.rows.iter().enumerate() {
let mut grid_row = Vec::with_capacity(num_cols);
for c in 0..num_cols {
let (ar, ac) = anchors[r * num_cols + c];
if (ar, ac) == (r, c) {
let (er, ec) = extent[r * num_cols + c];
let text = row.get(c).map(|s| cell_text(s)).unwrap_or_default();
let column_header = match s.filter(|s| !s.col_header.is_empty()) {
Some(s) => flag(Some(&s.col_header), r, c),
None => r == 0,
};
slot[r * num_cols + c] = Some(cells.len());
cells.push(cell_value(
er - r + 1,
ec - c + 1,
r,
er + 1,
c,
ec + 1,
text,
column_header,
flag(s.map(|s| &s.row_header), r, c),
false,
None,
));
}
grid_row.push(match slot[ar * num_cols + ac] {
Some(i) => cells[i].clone(),
None => Value::Null,
});
}
grid.push(grid_row);
}
}
json!({
"table_cells": cells,
"num_rows": num_rows,
"num_cols": num_cols,
"orientation": "rot_0",
"grid": grid,
})
}
#[derive(Clone, Copy)]
enum ProvSlot {
None,
One {
page_no: usize,
bbox: [f64; 4],
bottom_left: bool,
charspan: [usize; 2],
},
}
impl ProvSlot {
fn origin(bottom_left: bool) -> &'static str {
if bottom_left {
"BOTTOMLEFT"
} else {
"TOPLEFT"
}
}
fn to_value(self) -> Value {
let ProvSlot::One {
page_no,
bbox: [l, t, r, b],
bottom_left,
charspan,
} = self
else {
return Value::Array(Vec::new());
};
let bbox = object([
("l", l.into()),
("t", t.into()),
("r", r.into()),
("b", b.into()),
("coord_origin", Self::origin(bottom_left).into()),
]);
let span = Value::Array(vec![charspan[0].into(), charspan[1].into()]);
Value::Array(vec![object([
("page_no", page_no.into()),
("bbox", bbox),
("charspan", span),
])])
}
fn clamp(&mut self, sizes: &PageSizes) {
if let ProvSlot::One { page_no, bbox, .. } = self {
if let Some(&(w, h)) = sizes.get(page_no) {
for (v, hi) in bbox.iter_mut().zip([w, h, w, h]) {
if v.is_finite() {
*v = round2(v.clamp(0.0, hi.max(0.0)));
}
}
}
}
}
}
impl serde::Serialize for ProvSlot {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::{SerializeMap, SerializeSeq};
let ProvSlot::One {
page_no,
bbox: [l, t, r, b],
bottom_left,
charspan,
} = *self
else {
return s.serialize_seq(Some(0))?.end();
};
struct Bbox([f64; 4], &'static str);
impl serde::Serialize for Bbox {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = s.serialize_map(Some(5))?;
for (k, v) in ["l", "t", "r", "b"].iter().zip(self.0) {
m.serialize_entry(k, &v)?;
}
m.serialize_entry("coord_origin", self.1)?;
m.end()
}
}
struct Entry(usize, Bbox, [usize; 2]);
impl serde::Serialize for Entry {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = s.serialize_map(Some(3))?;
m.serialize_entry("page_no", &self.0)?;
m.serialize_entry("bbox", &self.1)?;
m.serialize_entry("charspan", &self.2)?;
m.end()
}
}
let mut seq = s.serialize_seq(Some(1))?;
seq.serialize_element(&Entry(
page_no,
Bbox([l, t, r, b], Self::origin(bottom_left)),
charspan,
))?;
seq.end()
}
}
struct TextItem {
parent: Box<str>,
label: Box<str>,
prov: ProvSlot,
text: Box<str>,
}
impl TextItem {
fn into_value(self, index: usize) -> Value {
let text = String::from(self.text);
object([
("self_ref", Value::String(format!("#/texts/{index}"))),
("parent", ref_value(self.parent)),
("children", Value::Array(Vec::new())),
("content_layer", "body".into()),
("label", Value::String(self.label.into())),
("prov", self.prov.to_value()),
("orig", Value::String(text.clone())),
("text", Value::String(text)),
])
}
}
enum Item {
Text(Box<TextItem>),
Json(Box<Value>),
}
impl Item {
fn json(v: Value) -> Self {
Item::Json(Box::new(v))
}
fn value_mut(&mut self, index: usize) -> &mut Value {
if let Item::Text(t) = self {
let t = std::mem::replace(
t,
Box::new(TextItem {
parent: "".into(),
label: "".into(),
prov: ProvSlot::None,
text: "".into(),
}),
);
*self = Item::json(t.into_value(index));
}
match self {
Item::Json(v) => v,
Item::Text(_) => unreachable!("converted above"),
}
}
fn into_value(self, index: usize) -> Value {
match self {
Item::Text(t) => t.into_value(index),
Item::Json(v) => *v,
}
}
}
struct Texts<'a>(&'a [Item]);
impl serde::Serialize for Texts<'_> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::{SerializeMap, SerializeSeq};
struct Text<'a>(usize, &'a TextItem);
impl serde::Serialize for Text<'_> {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let Text(i, t) = *self;
let mut m = s.serialize_map(Some(8))?;
m.serialize_entry("self_ref", &format_args!("#/texts/{i}"))?;
m.serialize_entry("parent", &Child::Ref(t.parent.clone()))?;
m.serialize_entry("children", &[(); 0])?;
m.serialize_entry("content_layer", "body")?;
m.serialize_entry("label", &t.label)?;
m.serialize_entry("prov", &t.prov)?;
m.serialize_entry("orig", &t.text)?;
m.serialize_entry("text", &t.text)?;
m.end()
}
}
let mut seq = s.serialize_seq(Some(self.0.len()))?;
for (i, item) in self.0.iter().enumerate() {
match item {
Item::Text(t) => seq.serialize_element(&Text(i, t))?,
Item::Json(v) => seq.serialize_element(v)?,
}
}
seq.end()
}
}
enum Child {
Ref(Box<str>),
Json(Box<Value>),
}
impl Child {
fn json(v: Value) -> Self {
Child::Json(Box::new(v))
}
fn into_value(self) -> Value {
match self {
Child::Ref(r) => ref_value(r),
Child::Json(v) => *v,
}
}
}
impl serde::Serialize for Child {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
match self {
Child::Ref(r) => {
let mut m = s.serialize_map(Some(1))?;
m.serialize_entry("$ref", r)?;
m.end()
}
Child::Json(v) => v.serialize(s),
}
}
}
fn round2(v: f64) -> f64 {
(v * 100.0).round() / 100.0
}
#[derive(Default)]
struct Builder {
note_keys: bool,
texts: Vec<Item>,
groups: Vec<Value>,
tables: Vec<Value>,
pictures: Vec<Value>,
field_regions: Vec<Value>,
field_items: Vec<Value>,
key_value_items: Vec<Value>,
pages: Vec<(usize, f64, f64)>,
cur_page: usize,
cur_w: f64,
cur_h: f64,
pending_loc: Option<[u16; 4]>,
pending_exact: Option<ExactProv>,
pending_track: Option<(crate::tree::TreeTrack, String)>,
pending_siblings: Vec<Value>,
pending_after: Vec<Value>,
pending_body: Vec<Value>,
comment_groups: Vec<String>,
last_comment_section: Option<(String, String)>,
pending_comments: Vec<(String, Vec<usize>)>,
}
impl Builder {
fn take_prov(&mut self, char_len: usize) -> Value {
self.take_prov_slot(char_len).to_value()
}
fn take_prov_slot(&mut self, char_len: usize) -> ProvSlot {
let prov = self.prov_slot(char_len, false);
self.pending_exact = None;
self.pending_loc = None;
prov
}
fn prov_slot(&self, char_len: usize, span_over_text: bool) -> ProvSlot {
let r2 = round2;
if let Some(ExactProv {
page_no,
bbox: [l, t, r, b],
bottom_left,
charspan,
}) = self.pending_exact
{
let charspan = if span_over_text {
[0, char_len]
} else {
charspan
};
return ProvSlot::One {
page_no,
bbox: [r2(l), r2(t), r2(r), r2(b)],
bottom_left,
charspan,
};
}
let Some([x0, y0, x1, y1]) = self.pending_loc else {
return ProvSlot::None;
};
if [x0, y0, x1, y1] == [0, 0, 0, 0] {
return ProvSlot::One {
page_no: self.cur_page,
bbox: [0.0; 4],
bottom_left: true,
charspan: [0, char_len],
};
}
ProvSlot::One {
page_no: self.cur_page,
bbox: [
r2(x0 as f64 * self.cur_w / 512.0),
r2(self.cur_h - y0 as f64 * self.cur_h / 512.0),
r2(x1 as f64 * self.cur_w / 512.0),
r2(self.cur_h - y1 as f64 * self.cur_h / 512.0),
],
bottom_left: true,
charspan: [0, char_len],
}
}
fn adopt_loc(&mut self, loc: Option<[u16; 4]>) {
if self.pending_loc.is_none() && self.cur_page > 0 {
self.pending_loc = loc;
}
}
fn link_comments(&mut self) {
let refs: Vec<(String, Vec<Value>)> = std::mem::take(&mut self.pending_comments)
.into_iter()
.map(|(item, comments)| {
let refs = comments
.iter()
.filter_map(|i| self.comment_groups.get(*i))
.map(ref_value)
.collect();
(item, refs)
})
.collect();
for (item, comment_refs) in refs {
if comment_refs.is_empty() {
continue;
}
let Some(target) = self.item_mut(&item) else {
continue;
};
let Some(obj) = target.as_object_mut() else {
continue;
};
let tail: Vec<(String, Value)> = obj
.iter()
.skip_while(|(k, _)| k.as_str() != "prov")
.skip(1)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
for (k, _) in &tail {
obj.shift_remove(k);
}
obj.insert("comments".into(), Value::Array(comment_refs));
for (k, v) in tail {
obj.insert(k, v);
}
}
}
fn item_mut(&mut self, self_ref: &str) -> Option<&mut Value> {
let idx = ref_index(self_ref)?;
if self_ref.starts_with("#/texts/") {
return self.texts.get_mut(idx).map(|item| item.value_mut(idx));
}
let bucket = if self_ref.starts_with("#/tables/") {
&mut self.tables
} else if self_ref.starts_with("#/pictures/") {
&mut self.pictures
} else if self_ref.starts_with("#/groups/") {
&mut self.groups
} else {
return None;
};
bucket.get_mut(idx)
}
fn write_tree(&mut self, tree: &crate::tree::ItemTree) -> Vec<Value> {
use crate::tree::TreeKind;
let mut refs: Vec<String> = Vec::with_capacity(tree.items.len());
let (mut nt, mut ng, mut ntb, mut np, mut nf, mut nk) = (0, 0, 0, 0, 0, 0);
for item in &tree.items {
if item.deleted {
refs.push(String::new());
continue;
}
let r = match &item.kind {
TreeKind::Text { .. } | TreeKind::Code { .. } => {
nt += 1;
format!("#/texts/{}", nt - 1)
}
TreeKind::Group { .. } => {
ng += 1;
format!("#/groups/{}", ng - 1)
}
TreeKind::Table { .. } => {
ntb += 1;
format!("#/tables/{}", ntb - 1)
}
TreeKind::Picture { .. } => {
np += 1;
format!("#/pictures/{}", np - 1)
}
TreeKind::FieldRegion { items } => {
nt += items
.iter()
.map(|i| {
[&i.marker, &i.key, &i.value]
.iter()
.filter(|p| p.is_some())
.count()
})
.sum::<usize>();
nf += 1;
format!("#/field_regions/{}", nf - 1)
}
TreeKind::KeyValueGraph { .. } => {
nk += 1;
format!("#/key_value_items/{}", nk - 1)
}
};
refs.push(r);
}
let ref_of = |id: usize| ref_value(refs[id].as_str());
for (id, item) in tree.items.iter().enumerate() {
if item.deleted {
continue;
}
let parent = item.parent.map_or("#/body", |p| refs[p].as_str());
let children: Vec<Value> = item
.children
.iter()
.filter(|&&c| !tree.items[c].deleted)
.map(|&c| ref_of(c))
.collect();
let layer = item.layer.map_or("body", |l| l.value());
self.pending_exact = item.prov.as_ref().map(ExactProv::from);
let self_ref = match &item.kind {
TreeKind::Text {
label,
text,
orig,
formatting,
hyperlink,
level,
list,
} => {
let mut tail = serde_json::Map::new();
if let Some(f) = formatting {
tail.insert("formatting".into(), formatting_json(f));
}
if let Some(h) = hyperlink {
tail.insert("hyperlink".into(), json!(h));
}
if let Some(l) = level {
tail.insert("level".into(), json!(l));
}
if let Some(l) = list {
tail.insert("enumerated".into(), json!(l.enumerated));
tail.insert("marker".into(), json!(l.marker));
}
let r = format!("#/texts/{}", self.texts.len());
let prov = self.take_prov(text.chars().count());
let mut item_json = json!({
"self_ref": r,
"parent": { "$ref": parent },
"children": children,
"content_layer": layer,
"label": label,
"prov": prov,
});
if !item.comments.is_empty() {
item_json["comments"] =
Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
}
if let Some(track) = &item.source {
item_json["source"] = json!([track_json(track)]);
}
merge(
&mut item_json,
json!({
"orig": orig.as_deref().unwrap_or(text),
"text": text,
}),
);
merge(&mut item_json, Value::Object(tail));
if self.note_keys {
if !item.notes.is_empty() {
item_json["_notes"] = Value::Array(
item.notes
.iter()
.map(|n| json!([n.offset, n.text]))
.collect(),
);
}
if item.note_body {
item_json["_note_body"] = json!(true);
}
}
self.texts.push(Item::json(item_json));
r
}
TreeKind::Code {
text,
orig,
language,
formatting,
hyperlink,
} => {
let r = format!("#/texts/{}", self.texts.len());
let prov = self.take_prov(text.chars().count());
let mut item_json = json!({
"self_ref": r,
"parent": { "$ref": parent },
"children": children,
"content_layer": layer,
"label": "code",
"prov": prov,
});
if !item.comments.is_empty() {
item_json["comments"] =
Value::Array(item.comments.iter().map(|&c| ref_of(c)).collect());
}
merge(
&mut item_json,
json!({
"orig": orig.as_deref().unwrap_or(text),
"text": text,
}),
);
if let Some(f) = formatting {
item_json["formatting"] = formatting_json(f);
}
if let Some(h) = hyperlink {
item_json["hyperlink"] = json!(h);
}
merge(
&mut item_json,
json!({
"captions": [],
"references": [],
"footnotes": [],
"code_language": code_language(language.as_deref()),
}),
);
self.texts.push(Item::json(item_json));
r
}
TreeKind::Group { label, name } => {
self.pending_exact = None;
let r = format!("#/groups/{}", self.groups.len());
self.groups.push(json!({
"self_ref": r,
"parent": { "$ref": parent },
"children": children,
"content_layer": layer,
"name": name,
"label": label,
}));
r
}
TreeKind::Table {
table,
rich_cells,
captions,
} => {
let r = self.add_table_with(table, parent, true);
let idx = ref_index(&r).expect("table ref");
let t = &mut self.tables[idx];
t["children"] = Value::Array(children);
t["content_layer"] = json!(layer);
t["captions"] = Value::Array(captions.iter().map(|&c| ref_of(c)).collect());
for &(row, col, group) in rich_cells {
let cell_ref = ref_of(group);
let hit = |c: &Value| {
c["start_row_offset_idx"] == json!(row)
&& c["start_col_offset_idx"] == json!(col)
};
if let Some(cells) = t["data"]["table_cells"].as_array_mut() {
for c in cells.iter_mut().filter(|c| hit(c)) {
c["ref"] = cell_ref.clone();
}
}
}
r
}
TreeKind::Picture {
captions,
image,
classification,
description,
confidence,
chart,
dpi,
} => {
let mut meta = classification.as_ref().map(|c| {
let mut pred = serde_json::Map::new();
if let Some(conf) = confidence {
pred.insert("confidence".into(), json!(conf));
}
pred.insert("class_name".into(), json!(c));
json!({ "classification": { "predictions": [pred] } })
});
if let (Some(m), Some(t)) = (meta.as_mut(), chart) {
if !t.rows.is_empty() {
m["tabular_chart"] = json!({ "chart_data": table_data(t) });
}
}
if let Some(desc) = description {
let mut d = description_meta(desc);
let mut merged = Map::new();
merged.insert("description".into(), d["description"].take());
if let Some(Value::Object(rest)) = meta {
merged.extend(rest);
}
merged.insert("annotations".into(), d["annotations"].take());
meta = Some(Value::Object(merged));
}
let prov = self.take_prov(0);
let r = self.push_picture(
prov,
captions.iter().map(|&c| ref_of(c)).collect(),
children,
image.as_ref(),
meta,
parent,
);
if let Some(idx) = ref_index(&r) {
self.pictures[idx]["content_layer"] = json!(layer);
if let (Some(dpi), Some(img)) = (dpi, self.pictures[idx].get_mut("image")) {
img["dpi"] = json!(dpi);
}
}
r
}
TreeKind::FieldRegion { items } => {
self.pending_exact = None;
let r = self.add_field_region(items, parent);
if let Some(region) = self.field_regions.last_mut() {
region["content_layer"] = json!(layer);
}
r
}
TreeKind::KeyValueGraph { cells, links } => {
self.pending_exact = None;
let r = self.add_key_value_graph(cells, links, parent);
if let Some(item) = self.key_value_items.last_mut() {
item["content_layer"] = json!(layer);
}
r
}
};
debug_assert_eq!(self_ref, refs[id], "tree item {id} numbered out of order");
}
tree.body
.iter()
.filter(|&&c| !tree.items[c].deleted)
.map(|&c| ref_of(c))
.collect()
}
fn add_node(&mut self, node: &Node, parent: &str) -> Option<String> {
match node {
Node::Heading { level: 1, text } => {
Some(self.add_text("title", text, parent, json!({})))
}
Node::Heading { level, text } => Some(self.add_text(
"section_header",
text,
parent,
json!({ "level": level.saturating_sub(1) }),
)),
Node::Caption { text, href } => {
let extra = match href {
Some(url) => json!({ "hyperlink": url }),
None => json!({}),
};
Some(self.add_text("caption", text, parent, extra))
}
Node::LabeledText { label, text, href } => {
let extra = match href {
Some(url) => json!({ "hyperlink": url }),
None => json!({}),
};
Some(self.add_text(label, text, parent, extra))
}
Node::Paragraph { text } => {
let t = text.trim();
match t.strip_prefix("$$").and_then(|s| s.strip_suffix("$$")) {
Some(inner) if !inner.is_empty() => Some(self.add_formula(inner, parent)),
_ => Some(self.add_text("text", text, parent, json!({}))),
}
}
Node::CheckboxItem { checked, text } => {
let label = if *checked {
"checkbox_selected"
} else {
"checkbox_unselected"
};
Some(self.add_text(label, text, parent, json!({})))
}
Node::Code {
language,
text,
orig,
..
} => Some(self.add_code(text, language.as_deref(), orig.as_deref(), parent)),
Node::Formula {
latex,
orig,
location,
} => {
self.adopt_loc(*location);
Some(self.add_formula_item(latex, orig, parent))
}
Node::CommentSection {
name,
text,
refs_note_text,
grouped,
} => {
if !*grouped {
let child =
self.add_text("text", text, parent, json!({ "content_layer": "notes" }));
self.comment_groups.push(child.clone());
return Some(child);
}
if let Some((_, self_ref)) = self
.last_comment_section
.as_ref()
.filter(|(n, _)| n == name)
.cloned()
{
let child =
self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
if let Some(children) =
self.groups[group_index(&self_ref)]["children"].as_array_mut()
{
children.push(ref_value(child.as_str()));
}
self.comment_groups.push(if *refs_note_text {
child
} else {
self_ref.clone()
});
return Some(self_ref);
}
let self_ref = format!("#/groups/{}", self.groups.len());
self.groups.push(Value::Null);
let child =
self.add_text("text", text, &self_ref, json!({ "content_layer": "notes" }));
self.groups[group_index(&self_ref)] = json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": [{ "$ref": child }],
"content_layer": "notes",
"name": name,
"label": "comment_section",
});
self.last_comment_section = Some((name.clone(), self_ref.clone()));
self.comment_groups.push(if *refs_note_text {
child
} else {
self_ref.clone()
});
Some(self_ref)
}
Node::Commented { comments, inner } => {
let item = self.add_node(inner, parent)?;
if !comments.is_empty() {
self.pending_comments.push((item.clone(), comments.clone()));
}
Some(item)
}
Node::Table(t) => Some(self.add_table(t, parent)),
Node::Picture {
caption,
caption_href,
image,
classification,
description,
caption_parent,
caption_location,
} => Some(self.add_picture(
caption.as_deref(),
caption_href.as_deref(),
image.as_ref(),
picture_meta(classification.as_deref(), description.as_ref()),
parent,
(*caption_parent, *caption_location),
)),
Node::Chart {
kind,
table,
caption,
location,
} => {
self.adopt_loc(*location);
let mut meta = json!({
"classification": { "predictions": [{ "class_name": kind }] },
});
if !table.rows.is_empty() {
meta["tabular_chart"] = json!({ "chart_data": table_data(table) });
}
let mut captions = Vec::new();
if let Some(cap) = caption.as_deref().filter(|c| !c.is_empty()) {
let prov = self.prov_slot(unescape_text(cap).chars().count(), true);
let cap_ref = self.add_text_with("caption", cap, parent, json!({}), prov);
self.pending_siblings.push(ref_value(cap_ref.as_str()));
captions.push(ref_value(cap_ref));
}
let prov = self.take_prov(0);
Some(self.push_picture(prov, captions, Vec::new(), None, Some(meta), parent))
}
Node::DoclangOnly(_) => None,
Node::Group {
label,
name,
layer,
children,
} => Some(self.add_group(label, name.as_deref(), *layer, children, parent)),
Node::FieldRegion { items } => Some(self.add_field_region(items, parent)),
Node::KeyValueGraph { cells, links } => {
Some(self.add_key_value_graph(cells, links, parent))
}
Node::InlineGroup { md_text, .. } => {
Some(self.add_text("text", md_text, parent, json!({})))
}
Node::TextDump(text) => Some(self.add_text("text", text, parent, json!({}))),
Node::Furniture {
layer: ContentLayer::Notes,
inner,
} => {
let item = self.add_node(inner, parent)?;
self.set_layer(&item, "notes");
Some(item)
}
Node::Furniture { .. } => None,
Node::PageFurniture {
footer,
location,
text,
} => {
if self.cur_page > 0 {
self.pending_loc = Some(*location);
}
let label = if *footer {
"page_footer"
} else {
"page_header"
};
let item = self.add_text(label, text, parent, json!({}));
self.pending_loc = None;
self.set_layer(&item, "furniture");
Some(item)
}
Node::FurnitureText { label, text } => {
let item = self.add_text(label, text, parent, json!({}));
self.set_layer(&item, "furniture");
Some(item)
}
Node::PictureChildren(children) => {
let pic = self.pictures.len().checked_sub(1)?;
let pic_ref = format!("#/pictures/{pic}");
let refs = self.walk_into(children, &pic_ref);
if let Some(list) = self.pictures[pic]["children"].as_array_mut() {
list.extend(refs.into_iter().map(Child::into_value));
}
None
}
Node::Located { location, inner } => {
if self.cur_page > 0 {
self.pending_loc = Some(*location);
}
let r = self.add_node(inner, parent);
self.pending_loc = None;
r
}
Node::Track { track, cue, inner } => {
self.pending_track = Some((track.clone(), cue.clone()));
let r = self.add_node(inner, parent);
self.pending_track = None;
r
}
Node::Prov {
page_no,
bbox,
charspan,
inner,
..
} => {
self.pending_exact = Some(ExactProv {
page_no: *page_no,
bbox: bbox.map(f64::from),
bottom_left: false,
charspan: *charspan,
});
let r = self.add_node(inner, parent);
self.pending_exact = None;
r
}
Node::PageBreak => None,
Node::PageInfo {
page_no,
width,
height,
} => {
self.cur_page = *page_no;
self.cur_w = *width as f64;
self.cur_h = *height as f64;
if *page_no > 0 {
self.pages.push((*page_no, self.cur_w, self.cur_h));
}
None
}
Node::ListItem { .. } => None,
}
}
fn add_field_region(&mut self, items: &[crate::FieldItem], parent: &str) -> String {
let self_ref = format!("#/field_regions/{}", self.field_regions.len());
self.field_regions.push(Value::Null);
let region_index = self.field_regions.len() - 1;
let mut item_refs = Vec::new();
for item in items {
item_refs.push(ref_value(self.add_field_item(item, &self_ref)));
}
self.field_regions[region_index] = json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": item_refs,
"content_layer": "body",
"label": "field_region",
"prov": [],
});
self_ref
}
fn add_key_value_graph(
&mut self,
cells: &[crate::GraphCell],
links: &[crate::GraphLink],
parent: &str,
) -> String {
let self_ref = format!("#/key_value_items/{}", self.key_value_items.len());
let cells: Vec<Value> = cells
.iter()
.map(|c| {
json!({
"label": c.label,
"cell_id": c.cell_id,
"text": c.text,
"orig": c.orig,
})
})
.collect();
let links: Vec<Value> = links
.iter()
.map(|l| {
json!({
"label": l.label,
"source_cell_id": l.source_cell_id,
"target_cell_id": l.target_cell_id,
})
})
.collect();
self.key_value_items.push(json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": [],
"content_layer": "body",
"label": "key_value_region",
"prov": [],
"captions": [],
"references": [],
"footnotes": [],
"graph": { "cells": cells, "links": links },
}));
self_ref
}
fn add_field_item(&mut self, item: &crate::FieldItem, parent: &str) -> String {
let self_ref = format!("#/field_items/{}", self.field_items.len());
self.field_items.push(Value::Null);
let item_index = self.field_items.len() - 1;
let mut child_refs = Vec::new();
for (label, text) in [
("marker", &item.marker),
("field_key", &item.key),
("field_value", &item.value),
] {
if let Some(text) = text {
let extra = match (label, &item.value_kind) {
("field_value", Some(kind)) => json!({ "kind": kind }),
_ => json!({}),
};
child_refs.push(ref_value(self.add_text(label, text, &self_ref, extra)));
}
}
self.field_items[item_index] = json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": child_refs,
"content_layer": "body",
"label": "field_item",
"prov": [],
});
self_ref
}
fn set_layer(&mut self, self_ref: &str, layer: &str) {
if let Some(item) = self.item_mut(self_ref) {
item["content_layer"] = json!(layer);
}
}
fn add_text(&mut self, label: &str, text: &str, parent: &str, extra: Value) -> String {
let prov = self.take_prov_slot(unescape_text(text).chars().count());
self.add_text_with(label, text, parent, extra, prov)
}
fn add_text_with(
&mut self,
label: &str,
text: &str,
parent: &str,
extra: Value,
prov: ProvSlot,
) -> String {
let index = self.texts.len();
let self_ref = format!("#/texts/{index}");
let mut item = match self.pending_track.take() {
None => Item::Text(Box::new(TextItem {
parent: parent.into(),
label: label.into(),
prov,
text: unescape_text(text).into(),
})),
Some((track, cue)) => {
let mut m = serde_json::Map::with_capacity(9);
m.insert("self_ref".into(), Value::String(self_ref.clone()));
m.insert("parent".into(), ref_value(parent));
m.insert("children".into(), Value::Array(Vec::new()));
m.insert("content_layer".into(), "body".into());
m.insert("label".into(), label.into());
m.insert("prov".into(), prov.to_value());
m.insert("source".into(), Value::Array(vec![track_json(&track)]));
m.insert("orig".into(), Value::String(cue.clone()));
m.insert("text".into(), Value::String(cue));
Item::json(Value::Object(m))
}
};
if extra.as_object().is_some_and(|e| !e.is_empty()) {
merge(item.value_mut(index), extra);
}
self.texts.push(item);
self_ref
}
fn add_formula(&mut self, latex: &str, parent: &str) -> String {
let self_ref = format!("#/texts/{}", self.texts.len());
let prov = self.take_prov(latex.chars().count());
self.texts.push(Item::json(json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": [],
"content_layer": "body",
"label": "formula",
"prov": prov,
"orig": latex,
"text": latex,
})));
self_ref
}
fn add_formula_item(&mut self, latex: &str, orig: &str, parent: &str) -> String {
let self_ref = format!("#/texts/{}", self.texts.len());
let prov = self.take_prov(latex.chars().count());
self.texts.push(Item::json(json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": [],
"content_layer": "body",
"label": "formula",
"prov": prov,
"orig": orig,
"text": latex,
})));
self_ref
}
fn add_code(
&mut self,
text: &str,
language: Option<&str>,
orig: Option<&str>,
parent: &str,
) -> String {
let self_ref = format!("#/texts/{}", self.texts.len());
let raw = unescape_text(text);
let prov = self.take_prov(raw.chars().count());
self.texts.push(Item::json(json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": [],
"content_layer": "body",
"label": "code",
"prov": prov,
"orig": orig.map(unescape_text).unwrap_or_else(|| raw.clone()),
"text": raw,
"captions": [],
"references": [],
"footnotes": [],
"code_language": code_language(language),
})));
self_ref
}
fn add_list(&mut self, items: &[Node], parent: &str) -> String {
let self_ref = format!("#/groups/{}", self.groups.len());
self.groups.push(Value::Null);
let base = level_of(&items[0]);
let mut children = Vec::new();
let mut i = 0;
while i < items.len() {
if !matches!(items[i], Node::ListItem { .. }) {
i += 1;
continue;
}
let lvl = level_of(&items[i]);
if lvl > base {
i += 1;
continue;
}
let item_ref = self.add_list_item(&items[i], &self_ref);
let mut j = i + 1;
while j < items.len() && level_of(&items[j]) > base {
j += 1;
}
if j > i + 1 {
let mut nested = Vec::new();
self.add_sibling_lists(&items[i + 1..j], &item_ref, &mut nested);
if let Some(idx) = ref_index(&item_ref) {
self.texts[idx].value_mut(idx)["children"]
.as_array_mut()
.unwrap()
.extend(nested);
}
}
children.push(ref_value(item_ref));
i = j;
}
self.groups[group_index(&self_ref)] = json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": children,
"content_layer": "body",
"name": "list",
"label": "list",
});
self_ref
}
fn add_list_item(&mut self, node: &Node, parent: &str) -> String {
let Node::ListItem {
ordered,
number,
text,
location,
..
} = node
else {
unreachable!()
};
self.adopt_loc(*location);
let self_ref = format!("#/texts/{}", self.texts.len());
let raw = unescape_text(text);
let prov = self.take_prov(raw.chars().count());
let marker = if *ordered {
format!("{number}.")
} else {
"-".to_string()
};
self.texts.push(Item::json(json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": [],
"content_layer": "body",
"label": "list_item",
"prov": prov,
"orig": raw,
"text": raw,
"enumerated": ordered,
"marker": marker,
})));
self_ref
}
fn add_table(&mut self, t: &Table, parent: &str) -> String {
self.add_table_with(t, parent, false)
}
fn add_table_with(&mut self, t: &Table, parent: &str, raw: bool) -> String {
let self_ref = format!("#/tables/{}", self.tables.len());
self.adopt_loc(t.location);
let prov = self.take_prov(0);
let (captions, children) = match t.caption.as_deref().filter(|c| !c.is_empty()) {
Some(cap) => self.add_caption(
cap,
json!({}),
&self_ref,
parent,
(t.caption_parent, t.caption_location),
),
None => (Vec::new(), Vec::new()),
};
let data = table_data_with(t, raw);
self.tables.push(json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": children,
"content_layer": "body",
"label": "table",
"prov": prov,
"captions": captions,
"references": [],
"footnotes": [],
"data": data,
"annotations": [],
}));
self_ref
}
fn add_caption(
&mut self,
text: &str,
extra: Value,
self_ref: &str,
parent: &str,
(choice, location): (CaptionParent, Option<[u16; 4]>),
) -> (Vec<Value>, Vec<Value>) {
let cap_parent = match choice {
CaptionParent::Item => self_ref,
CaptionParent::Container | CaptionParent::ContainerAfter => parent,
CaptionParent::Body => "#/body",
};
if self.cur_page > 0 && location.is_some() {
self.pending_loc = location;
}
let cap_ref = ref_value(self.add_text("caption", text, cap_parent, extra));
match choice {
CaptionParent::Item => return (vec![cap_ref.clone()], vec![cap_ref]),
CaptionParent::Container => self.pending_siblings.push(cap_ref.clone()),
CaptionParent::Body if parent == "#/body" => {
self.pending_siblings.push(cap_ref.clone())
}
CaptionParent::ContainerAfter => self.pending_after.push(cap_ref.clone()),
CaptionParent::Body => self.pending_body.push(cap_ref.clone()),
}
(vec![cap_ref], Vec::new())
}
fn add_picture(
&mut self,
caption: Option<&str>,
caption_href: Option<&str>,
image: Option<&crate::PictureImage>,
meta: Option<Value>,
parent: &str,
caption_at: (CaptionParent, Option<[u16; 4]>),
) -> String {
let self_ref = format!("#/pictures/{}", self.pictures.len());
let prov = self.take_prov(0);
let (captions, children) = match caption.filter(|c| !c.is_empty()) {
Some(cap) => {
let extra = match caption_href {
Some(href) => json!({ "hyperlink": href }),
None => json!({}),
};
self.add_caption(cap, extra, &self_ref, parent, caption_at)
}
None => (Vec::new(), Vec::new()),
};
self.push_picture(prov, captions, children, image, meta, parent)
}
fn push_picture(
&mut self,
prov: Value,
captions: Vec<Value>,
children: Vec<Value>,
image: Option<&crate::PictureImage>,
meta: Option<Value>,
parent: &str,
) -> String {
let self_ref = format!("#/pictures/{}", self.pictures.len());
let annotations = meta
.as_ref()
.and_then(|m| m.get("annotations").cloned())
.unwrap_or_else(|| json!([]));
let meta = meta.map(|mut m| {
if let Some(obj) = m.as_object_mut() {
obj.remove("annotations");
}
m
});
let mut item = match meta {
Some(meta) => json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": children,
"content_layer": "body",
"meta": meta,
"label": "picture",
"prov": prov,
"captions": captions,
"references": [],
"footnotes": [],
"annotations": annotations,
}),
None => json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": children,
"content_layer": "body",
"label": "picture",
"prov": prov,
"captions": captions,
"references": [],
"footnotes": [],
"annotations": annotations,
}),
};
if let Some(img) = image {
let image = image_ref_json(img);
if let Some(obj) = item.as_object_mut() {
let annotations = obj.remove("annotations").unwrap_or_else(|| json!([]));
obj.insert("image".into(), image);
obj.insert("annotations".into(), annotations);
}
}
self.pictures.push(item);
self_ref
}
fn add_group(
&mut self,
label: &str,
name: Option<&str>,
layer: Option<ContentLayer>,
nodes: &[Node],
parent: &str,
) -> String {
let self_ref = format!("#/groups/{}", self.groups.len());
self.groups.push(Value::Null);
let mark = (
self.texts.len(),
self.tables.len(),
self.pictures.len(),
self.groups.len(),
);
let children: Vec<Value> = self
.walk_into(nodes, &self_ref)
.into_iter()
.map(Child::into_value)
.collect();
let name = name.unwrap_or(if label == "inline" { "group" } else { label });
let content_layer = layer.map_or("body", |l| l.value());
self.groups[group_index(&self_ref)] = json!({
"self_ref": self_ref,
"parent": { "$ref": parent },
"children": children,
"content_layer": content_layer,
"name": name,
"label": label,
});
if layer.is_some() {
let (t, tb, p, g) = mark;
for item in self.texts[t..]
.iter_mut()
.enumerate()
.map(|(k, item)| item.value_mut(t + k))
.chain(self.tables[tb..].iter_mut())
.chain(self.pictures[p..].iter_mut())
.chain(self.groups[g..].iter_mut())
{
if let Some(obj) = item.as_object_mut() {
obj.insert("content_layer".into(), json!(content_layer));
}
}
}
self_ref
}
fn walk_into(&mut self, nodes: &[Node], parent: &str) -> Vec<Child> {
let seqs: Option<Vec<usize>> = nodes
.iter()
.map(|n| match n {
Node::Prov { seq: Some(s), .. } => Some(*s),
_ => None,
})
.collect();
if let Some(seqs) = seqs.filter(|s| !s.is_empty()) {
let mut order: Vec<usize> = (0..nodes.len()).collect();
order.sort_by_key(|&i| seqs[i]);
let mut slots: Vec<Vec<Child>> = (0..nodes.len()).map(|_| Vec::new()).collect();
for i in order {
if let Some(r) = self.add_node(&nodes[i], parent) {
slots[i].extend(self.pending_siblings.drain(..).map(Child::json));
slots[i].push(Child::Ref(r.into()));
slots[i].extend(self.pending_after.drain(..).map(Child::json));
}
if parent == "#/body" {
slots[i].extend(self.pending_body.drain(..).map(Child::json));
}
}
return slots.into_iter().flatten().collect();
}
let mut children = Vec::new();
let mut i = 0;
while i < nodes.len() {
if matches!(nodes[i], Node::ListItem { .. }) {
let start = i;
i += 1;
loop {
match nodes.get(i) {
Some(Node::ListItem { .. }) => i += 1,
Some(Node::Paragraph { text })
if text.is_empty()
&& matches!(nodes.get(i + 1), Some(Node::ListItem { .. })) =>
{
i += 1
}
_ => break,
}
}
let mut lists = Vec::new();
self.add_sibling_lists(&nodes[start..i], parent, &mut lists);
children.extend(lists.into_iter().map(Child::json));
} else {
if let Some(r) = self.add_node(&nodes[i], parent) {
children.extend(self.pending_siblings.drain(..).map(Child::json));
children.push(Child::Ref(r.into()));
children.extend(self.pending_after.drain(..).map(Child::json));
}
i += 1;
}
if parent == "#/body" {
children.extend(self.pending_body.drain(..).map(Child::json));
}
}
children
}
fn add_sibling_lists(&mut self, run: &[Node], parent: &str, out: &mut Vec<Value>) {
let base = level_of(&run[0]);
let mut seg = 0;
for k in 0..run.len() {
let Node::ListItem {
first_in_list,
level,
..
} = &run[k]
else {
continue;
};
if *level != base {
continue; }
if k > seg && *first_in_list {
out.push(ref_value(self.add_list(&run[seg..k], parent)));
seg = k;
}
}
out.push(ref_value(self.add_list(&run[seg..], parent)));
}
}
fn level_of(node: &Node) -> u8 {
match node {
Node::ListItem { level, .. } => *level,
_ => 0,
}
}
fn group_index(self_ref: &str) -> usize {
self_ref.rsplit('/').next().unwrap().parse().unwrap()
}
fn ref_index(self_ref: &str) -> Option<usize> {
self_ref.rsplit('/').next()?.parse().ok()
}
fn merge(target: &mut Value, extra: Value) {
if let (Some(t), Value::Object(e)) = (target.as_object_mut(), extra) {
t.extend(e);
}
}
fn object<const N: usize>(entries: [(&str, Value); N]) -> Value {
let mut m = serde_json::Map::with_capacity(N);
for (k, v) in entries {
m.insert(k.to_owned(), v);
}
Value::Object(m)
}
fn ref_value(r: impl Into<String>) -> Value {
object([("$ref", Value::String(r.into()))])
}
fn unescape_text(s: &str) -> String {
s.replace("<", "<")
.replace(">", ">")
.replace("&", "&")
.replace("\\_", "_")
}
fn fnv1a(s: &str) -> u64 {
let mut h: u64 = 0xcbf29ce484222325;
for b in s.bytes() {
h ^= b as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
#[cfg(test)]
mod tests {
use crate::{
CaptionParent, ContentLayer, DoclingDocument, ImageMode, Node, PictureImage, Table,
};
use serde_json::Value;
fn cell_at(text: &str, row: usize, col: usize, col_span: usize) -> crate::TableCell {
crate::TableCell {
text: text.into(),
bbox: None,
start_row: row,
start_col: col,
row_span: 1,
col_span,
column_header: row == 0,
row_header: false,
row_section: false,
}
}
#[test]
fn ragged_rows_take_their_grid_from_first_class_cells() {
let cells = vec![
cell_at("h0", 0, 0, 1),
cell_at("h2", 0, 2, 1),
cell_at("wide", 1, 0, 3),
];
let export = |rows: Vec<Vec<String>>| -> Value {
let mut doc = DoclingDocument::new("t");
doc.push(Node::Table(Table {
rows,
cells: Some(cells.clone()),
..Table::default()
}));
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
v["tables"][0]["data"].clone()
};
let data = export(vec![
vec!["h0".into(), "h2".into()],
vec!["wide".into(), "wide".into(), "wide".into()],
]);
assert_eq!(data["num_rows"], 2);
assert_eq!(data["num_cols"], 3);
assert_eq!(data["table_cells"].as_array().unwrap().len(), 3);
let row0: Vec<&str> = data["grid"][0]
.as_array()
.unwrap()
.iter()
.map(|c| c["text"].as_str().unwrap())
.collect();
assert_eq!(row0, ["h0", "", "h2"]);
assert_eq!(data["table_cells"][2]["col_span"], 3);
let data = export(vec![
vec!["h0".into(), "h2".into()],
vec!["wide".into(), "wide".into()],
]);
assert_eq!(data["num_cols"], 2);
assert_eq!(data["grid"][0].as_array().unwrap().len(), 2);
}
fn doc_with_image() -> DoclingDocument {
let mut doc = DoclingDocument::new("t");
doc.push(Node::Picture {
caption: Some("Fig 1".into()),
caption_href: None,
image: Some(PictureImage {
dpi: crate::PictureImage::DEFAULT_DPI,
mimetype: "image/png".into(),
width: 4,
height: 2,
data: b"foobar".to_vec(),
}),
classification: None,
description: None,
caption_parent: Default::default(),
caption_location: None,
});
doc
}
#[test]
fn notes_layer_items_reach_the_json_but_furniture_does_not() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::Heading {
level: 1,
text: "Slide One".into(),
});
doc.push(Node::Furniture {
layer: ContentLayer::Notes,
inner: Box::new(Node::Located {
location: [0, 0, 0, 0],
inner: Box::new(Node::Paragraph {
text: "Speaker note for slide 1.".into(),
}),
}),
});
doc.push(Node::Furniture {
layer: ContentLayer::Furniture,
inner: Box::new(Node::Paragraph {
text: "page header".into(),
}),
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let texts = v["texts"].as_array().unwrap();
assert_eq!(
texts
.iter()
.map(|t| (
t["label"].as_str().unwrap(),
t["content_layer"].as_str().unwrap(),
t["text"].as_str().unwrap()
))
.collect::<Vec<_>>(),
vec![
("title", "body", "Slide One"),
("text", "notes", "Speaker note for slide 1."),
],
"the note is carried on its own layer; the furniture is not carried"
);
assert_eq!(doc.export_to_markdown(), "# Slide One\n");
}
#[test]
fn continuation_flags_become_spanning_cells() {
let mut doc = DoclingDocument::new("t");
let rows = vec![
vec!["merged".to_string(), "merged".into(), "merged".into()],
vec!["merged".to_string(), "merged".into(), "merged".into()],
vec!["a".to_string(), "b".into(), "c".into()],
];
doc.push(Node::Table(crate::Table {
rows,
location: None,
structure: Some(crate::TableStructure {
header_row: vec![true, false, false],
col_continuation: vec![
vec![false, true, true],
vec![false, true, true],
vec![false, false, false],
],
row_continuation: vec![
vec![false, false, false],
vec![true, true, true],
vec![false, false, false],
],
row_header: Vec::new(),
col_header: Vec::new(),
}),
cell_blocks: None,
cells: None,
caption: None,
caption_parent: Default::default(),
caption_location: None,
}));
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let data = &v["tables"][0]["data"];
assert_eq!(data["num_rows"], 3);
assert_eq!(data["num_cols"], 3);
let cells = data["table_cells"].as_array().unwrap();
assert_eq!(
cells.len(),
4,
"one cell for the range, three for the plain row"
);
assert_eq!(
cells[0],
serde_json::json!({
"row_span": 2, "col_span": 3,
"start_row_offset_idx": 0, "end_row_offset_idx": 2,
"start_col_offset_idx": 0, "end_col_offset_idx": 3,
"text": "merged", "column_header": true, "row_header": false,
"row_section": false, "fillable": false,
})
);
assert_eq!(cells[1]["text"], "a");
assert_eq!(cells[1]["row_span"], 1);
assert_eq!(cells[1]["column_header"], false);
let grid = data["grid"].as_array().unwrap();
assert_eq!(grid.len(), 3);
for (r, row) in grid.iter().take(2).enumerate() {
for (c, cell) in row.as_array().unwrap().iter().enumerate() {
assert_eq!(*cell, cells[0], "grid[{r}][{c}]");
}
}
assert_eq!(grid[2][2]["text"], "c");
}
#[test]
fn exact_provenance_pages_and_chart_captions_follow_docling() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::PageInfo {
page_no: 1,
width: 3.0,
height: 4.0,
});
let table = crate::Table {
rows: vec![vec!["a".to_string(), "b".into()]],
..Default::default()
};
doc.push(Node::Group {
label: "sheet".into(),
name: Some("Data".into()),
layer: None,
children: vec![
Node::Prov {
page_no: 1,
bbox: [0.0, 0.0, 3.0, 4.0],
charspan: [0, 0],
seq: Some(1),
inner: Box::new(Node::Table(table.clone())),
},
Node::Prov {
page_no: 1,
bbox: [0.0, 1.0, 1.0, 1.0],
charspan: [0, 0],
seq: Some(0),
inner: Box::new(Node::Chart {
kind: "bar_chart".into(),
table,
caption: Some("Sales".into()),
location: Some([0, 128, 170, 128]),
}),
},
],
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(
v["pages"],
serde_json::json!({"1": {"size": {"width": 3.0, "height": 4.0}, "page_no": 1}})
);
assert_eq!(
v["tables"][0]["prov"],
serde_json::json!([{
"page_no": 1,
"bbox": {"l": 0.0, "t": 0.0, "r": 3.0, "b": 4.0, "coord_origin": "TOPLEFT"},
"charspan": [0, 0],
}])
);
assert_eq!(v["tables"][0]["data"]["orientation"], "rot_0");
let sheet = &v["groups"][0];
assert_eq!(
sheet["children"],
serde_json::json!([
{"$ref": "#/tables/0"}, {"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}
])
);
let cap = &v["texts"][0];
assert_eq!(cap["label"], "caption");
assert_eq!(cap["parent"], serde_json::json!({"$ref": "#/groups/0"}));
assert_eq!(cap["prov"][0]["charspan"], serde_json::json!([0, 5]));
assert_eq!(cap["prov"][0]["bbox"]["b"], 1.0);
let pic = &v["pictures"][0];
assert_eq!(pic["captions"], serde_json::json!([{"$ref": "#/texts/0"}]));
assert_eq!(pic["prov"][0]["charspan"], serde_json::json!([0, 0]));
assert_eq!(pic["prov"][0]["bbox"]["coord_origin"], "TOPLEFT");
assert_eq!(
pic["meta"]["classification"]["predictions"][0]["class_name"],
"bar_chart"
);
assert_eq!(pic["meta"]["tabular_chart"]["chart_data"]["num_cols"], 2);
}
#[test]
fn a_zero_location_is_a_zero_bbox_not_the_whole_page() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::PageInfo {
page_no: 1,
width: 12192000.0,
height: 6858000.0,
});
doc.push(Node::Furniture {
layer: ContentLayer::Notes,
inner: Box::new(Node::Located {
location: [0, 0, 0, 0],
inner: Box::new(Node::Paragraph {
text: "a note".into(),
}),
}),
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let prov = &v["texts"][0]["prov"][0];
assert_eq!(prov["page_no"], 1);
assert_eq!(prov["charspan"], serde_json::json!([0, 6]));
assert_eq!(
prov["bbox"],
serde_json::json!({"l": 0.0, "t": 0.0, "r": 0.0, "b": 0.0, "coord_origin": "BOTTOMLEFT"})
);
assert_eq!(
v["pages"]["1"]["size"],
serde_json::json!({"width": 12192000.0, "height": 6858000.0})
);
}
#[test]
fn captions_and_checkboxes_carry_their_own_prov() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::PageInfo {
page_no: 1,
width: 512.0,
height: 512.0,
});
doc.push(Node::Located {
location: [64, 64, 448, 256],
inner: Box::new(Node::Picture {
caption: Some("Figure 1".into()),
caption_href: None,
image: None,
classification: None,
description: None,
caption_parent: CaptionParent::Item,
caption_location: Some([64, 264, 448, 280]),
}),
});
doc.push(Node::PageInfo {
page_no: 2,
width: 512.0,
height: 512.0,
});
doc.push(Node::Located {
location: [64, 120, 448, 300],
inner: Box::new(Node::Table(Table {
rows: vec![vec!["a".into()]],
caption: Some("Table 1".into()),
caption_parent: CaptionParent::Item,
caption_location: Some([64, 100, 300, 112]),
..Table::default()
})),
});
doc.push(Node::Located {
location: [64, 320, 200, 332],
inner: Box::new(Node::CheckboxItem {
checked: false,
text: "First option".into(),
}),
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let texts = v["texts"].as_array().unwrap();
let by_text = |t: &str| texts.iter().find(|x| x["text"] == t).expect(t).clone();
let bbox = |p: &Value| {
let b = &p["prov"][0]["bbox"];
[&b["l"], &b["t"], &b["r"], &b["b"]].map(|x| x.as_f64().unwrap())
};
let fig = by_text("Figure 1");
assert_eq!(fig["prov"][0]["page_no"], 1);
assert_eq!(bbox(&fig), [64.0, 248.0, 448.0, 232.0]);
assert_eq!(fig["prov"][0]["charspan"], serde_json::json!([0, 8]));
assert_eq!(fig["parent"]["$ref"], "#/pictures/0");
assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
let tab = by_text("Table 1");
assert_eq!(tab["prov"][0]["page_no"], 2);
assert_eq!(bbox(&tab), [64.0, 412.0, 300.0, 400.0]);
assert_eq!(v["tables"][0]["prov"][0]["bbox"]["t"], 392.0);
let cb = by_text("First option");
assert_eq!(cb["label"], "checkbox_unselected");
assert_eq!(cb["prov"][0]["page_no"], 2);
assert_eq!(bbox(&cb), [64.0, 192.0, 200.0, 180.0]);
assert!(texts
.iter()
.all(|t| !t["prov"].as_array().unwrap().is_empty()));
let mut plain = DoclingDocument::new("t");
plain.push(Node::PageInfo {
page_no: 1,
width: 512.0,
height: 512.0,
});
plain.push(Node::Located {
location: [64, 64, 448, 256],
inner: Box::new(Node::Picture {
caption: Some("Figure 1".into()),
caption_href: None,
image: None,
classification: None,
description: None,
caption_parent: CaptionParent::Item,
caption_location: None,
}),
});
let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
assert_eq!(v["pictures"][0]["prov"][0]["bbox"]["t"], 448.0);
}
#[test]
fn page_markers_produce_pages_and_prov() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::PageInfo {
page_no: 1,
width: 512.0,
height: 1024.0,
});
doc.push(Node::Located {
location: [128, 64, 256, 128], inner: Box::new(Node::Paragraph {
text: "hello".into(),
}),
});
doc.push(Node::Table(Table {
rows: vec![vec!["a".into()]],
location: Some([0, 0, 512, 512]),
..Table::default()
}));
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(v["pages"]["1"]["page_no"], 1);
assert_eq!(v["pages"]["1"]["size"]["width"], 512.0);
assert_eq!(v["pages"]["1"]["size"]["height"], 1024.0);
let prov = &v["texts"][0]["prov"][0];
assert_eq!(prov["page_no"], 1);
assert_eq!(prov["bbox"]["l"], 128.0);
assert_eq!(prov["bbox"]["t"], 896.0);
assert_eq!(prov["bbox"]["r"], 256.0);
assert_eq!(prov["bbox"]["b"], 768.0);
assert_eq!(prov["bbox"]["coord_origin"], "BOTTOMLEFT");
assert_eq!(prov["charspan"][1], 5);
let tprov = &v["tables"][0]["prov"][0];
assert_eq!(tprov["bbox"]["t"], 1024.0);
assert_eq!(tprov["bbox"]["b"], 0.0);
assert_eq!(tprov["charspan"][1], 0);
let mut plain = DoclingDocument::new("t");
plain.push(Node::Located {
location: [1, 2, 3, 4],
inner: Box::new(Node::Paragraph { text: "x".into() }),
});
let v: Value = serde_json::from_str(&plain.export_to_json()).unwrap();
assert_eq!(v["pages"], serde_json::json!({}));
assert_eq!(v["texts"][0]["prov"], serde_json::json!([]));
}
#[test]
fn page_furniture_becomes_furniture_layer_text() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::PageInfo {
page_no: 1,
width: 512.0,
height: 512.0,
});
doc.push(Node::PageFurniture {
footer: false,
location: [10, 0, 100, 20],
text: "Chapter 1".into(),
});
doc.push(Node::Paragraph {
text: "body".into(),
});
doc.push(Node::PageFurniture {
footer: true,
location: [400, 490, 500, 512],
text: "1.10.2".into(),
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let texts = v["texts"].as_array().unwrap();
assert_eq!(texts.len(), 3);
assert_eq!(texts[0]["label"], "page_header");
assert_eq!(texts[0]["content_layer"], "furniture");
assert_eq!(texts[0]["parent"]["$ref"], "#/body");
assert_eq!(texts[0]["prov"][0]["bbox"]["l"], 10.0);
assert_eq!(texts[1]["content_layer"], "body");
assert_eq!(texts[1]["prov"], serde_json::json!([]));
assert_eq!(texts[2]["label"], "page_footer");
assert_eq!(texts[2]["text"], "1.10.2");
assert_eq!(texts[2]["content_layer"], "furniture");
}
#[test]
fn picture_image_in_markdown_modes_and_json() {
let doc = doc_with_image();
assert!(doc.export_to_markdown().contains("<!-- image -->"));
let (md, files) = doc.export_to_markdown_with_images(ImageMode::Embedded, "artifacts");
assert!(
md.contains(""),
"got:\n{md}"
);
assert!(files.is_empty());
let (md, files) = doc.export_to_markdown_with_images(ImageMode::Referenced, "artifacts");
assert!(
md.contains(""),
"got:\n{md}"
);
assert_eq!(
files,
vec![("artifacts/image_000000.png".to_string(), b"foobar".to_vec())]
);
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(v["pictures"][0]["image"]["mimetype"], "image/png");
assert_eq!(v["pictures"][0]["image"]["size"]["width"], 4.0);
let keys: Vec<&str> = v["pictures"][0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(&keys[keys.len() - 2..], ["image", "annotations"]);
assert_eq!(
v["pictures"][0]["image"]["uri"],
"data:image/png;base64,Zm9vYmFy"
);
}
#[test]
fn exports_docling_schema() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::Heading {
level: 1,
text: "Title".into(),
});
doc.push(Node::Heading {
level: 2,
text: "Sec".into(),
});
doc.push(Node::Paragraph {
text: "Body & more".into(),
}); doc.push(Node::ListItem {
ordered: false,
number: 0,
first_in_list: true,
text: "one".into(),
level: 0,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
});
doc.push(Node::ListItem {
ordered: false,
number: 0,
first_in_list: false,
text: "two".into(),
level: 0,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
});
doc.push(Node::Table(Table {
rows: vec![vec!["A".into(), "B".into()]],
location: None,
structure: None,
cell_blocks: None,
cells: None,
caption: None,
caption_parent: Default::default(),
caption_location: None,
}));
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(v["schema_name"], "DoclingDocument");
assert_eq!(v["version"], "1.10.0");
assert_eq!(v["texts"][0]["label"], "title");
assert_eq!(v["texts"][1]["label"], "section_header");
assert_eq!(v["texts"][1]["level"], 1); assert_eq!(v["texts"][2]["text"], "Body & more"); assert_eq!(v["groups"][0]["label"], "list");
assert_eq!(v["groups"][0]["children"].as_array().unwrap().len(), 2);
assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
assert_eq!(v["texts"][3]["marker"], "-");
assert_eq!(v["tables"][0]["data"]["num_cols"], 2);
assert_eq!(v["tables"][0]["data"]["grid"][0][0]["column_header"], true);
}
#[test]
fn a_layered_group_stamps_its_whole_subtree() {
let doc = DoclingDocument {
name: "s".into(),
nodes: vec![
Node::Group {
label: "sheet".into(),
name: Some("Sheet1".into()),
layer: None,
children: vec![Node::Paragraph {
text: "visible".into(),
}],
},
Node::Group {
label: "sheet".into(),
name: Some("Sheet2".into()),
layer: Some(ContentLayer::Invisible),
children: vec![Node::Paragraph {
text: "hidden".into(),
}],
},
],
..DoclingDocument::new("s")
};
let v = crate::json::to_json(&doc);
assert_eq!(v["groups"][0]["label"], "sheet");
assert_eq!(v["groups"][0]["name"], "Sheet1");
assert_eq!(v["groups"][0]["content_layer"], "body");
assert_eq!(v["texts"][0]["content_layer"], "body");
assert_eq!(v["groups"][1]["name"], "Sheet2");
assert_eq!(v["groups"][1]["content_layer"], "invisible");
assert_eq!(v["texts"][1]["content_layer"], "invisible");
assert_eq!(v["groups"][1]["children"][0]["$ref"], "#/texts/1");
assert_eq!(v["body"]["children"][1]["$ref"], "#/groups/1");
}
#[test]
fn deleted_items_comment_refs_and_chart_meta_in_the_tree() {
use crate::tree::{ItemTree, TreeKind};
let mut t = ItemTree::default();
let text = |txt: &str| TreeKind::Text {
label: "text".into(),
text: txt.into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: None,
};
let a = t.add(None, None, text("a"));
let blank = t.add(None, None, text(""));
let b = t.add(None, None, text("b"));
t.delete(blank);
let group = t.add(
None,
Some(ContentLayer::Notes),
TreeKind::Group {
label: "comment_section".into(),
name: "comment-0".into(),
},
);
t.add(Some(group), Some(ContentLayer::Notes), text("note"));
t.items[a].comments.push(group);
t.add(
None,
None,
TreeKind::Picture {
captions: Vec::new(),
image: None,
classification: Some("bar_chart".into()),
description: None,
confidence: None,
chart: Some(Table {
rows: vec![vec!["".into(), "s".into()], vec!["c".into(), "1".into()]],
..Table::default()
}),
dpi: None,
},
);
assert_eq!(t.last_text(), Some(4), "the note; the blank is skipped");
assert_eq!(t.bucket_index(b), 1, "numbered past the deleted item");
let mut doc = DoclingDocument::new("t");
doc.tree = Some(t);
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let texts = v["texts"].as_array().unwrap();
assert_eq!(texts.len(), 3);
assert_eq!(texts[1]["text"], "b");
assert_eq!(texts[1]["self_ref"], "#/texts/1");
assert_eq!(
v["body"]["children"],
serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}, {"$ref": "#/groups/0"}, {"$ref": "#/pictures/0"}])
);
let keys: Vec<&str> = texts[0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
vec![
"self_ref",
"parent",
"children",
"content_layer",
"label",
"prov",
"comments",
"orig",
"text"
]
);
assert_eq!(
texts[0]["comments"],
serde_json::json!([{"$ref": "#/groups/0"}])
);
assert!(texts[1].get("comments").is_none());
let meta = &v["pictures"][0]["meta"];
assert_eq!(
meta["classification"]["predictions"][0]["class_name"],
"bar_chart"
);
assert_eq!(meta["tabular_chart"]["chart_data"]["num_rows"], 2);
}
#[test]
fn tree_items_carry_exact_provenance_and_dpi() {
use crate::tree::{ItemTree, TreeKind, TreeProv};
let text = |label: &str, t: &str| TreeKind::Text {
label: label.into(),
text: t.into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: None,
};
let mut t = ItemTree::default();
let slide = t.add(
None,
None,
TreeKind::Group {
label: "chapter".into(),
name: "slide-0".into(),
},
);
t.add_with_prov(
Some(slide),
None,
text("paragraph", "héllo"),
TreeProv {
page_no: 1,
bbox: [914400.0, 1828800.0, 2743200.0, 457200.0],
bottom_left: true,
charspan: [0, 5],
},
);
t.add_with_prov(
Some(slide),
None,
TreeKind::Picture {
captions: Vec::new(),
image: Some(crate::PictureImage {
dpi: crate::PictureImage::DEFAULT_DPI,
mimetype: "image/png".into(),
width: 2,
height: 2,
data: vec![0],
}),
classification: None,
description: None,
confidence: None,
chart: None,
dpi: Some(300),
},
TreeProv {
page_no: 1,
bbox: [0.0; 4],
bottom_left: false,
charspan: [0, 0],
},
);
t.add(
Some(slide),
Some(ContentLayer::Notes),
text("text", "no geometry"),
);
let mut doc = DoclingDocument::new("t");
doc.push(Node::PageInfo {
page_no: 1,
width: 9144000.0,
height: 6858000.0,
});
doc.tree = Some(t);
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(
v["texts"][0]["prov"],
serde_json::json!([{
"page_no": 1,
"bbox": { "l": 914400.0, "t": 1828800.0, "r": 2743200.0, "b": 457200.0, "coord_origin": "BOTTOMLEFT" },
"charspan": [0, 5],
}])
);
assert_eq!(v["texts"][0]["label"], "paragraph");
assert_eq!(
v["pictures"][0]["prov"][0]["bbox"]["coord_origin"],
"TOPLEFT"
);
assert_eq!(v["pictures"][0]["image"]["dpi"], 300);
assert_eq!(v["texts"][1]["prov"], serde_json::json!([]));
assert_eq!(v["texts"][1]["content_layer"], "notes");
assert_eq!(v["pages"]["1"]["size"]["width"], 9144000.0);
assert_eq!(v["pages"]["1"]["page_no"], 1);
}
#[test]
fn provenance_boxes_are_clamped_to_their_page() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::PageInfo {
page_no: 1,
width: 10.0,
height: 8.0,
});
doc.push(Node::Prov {
page_no: 1,
bbox: [-1.0, 2.0, 12.0, 9.5],
charspan: [0, 1],
seq: None,
inner: Box::new(Node::Paragraph { text: "x".into() }),
});
let mut table = Table {
rows: vec![vec!["a".into()]],
..Table::default()
};
table.cells = Some(vec![crate::TableCell {
text: "a".into(),
bbox: Some([1.0, 1.0, 11.0, 9.0]),
start_row: 0,
start_col: 0,
row_span: 1,
col_span: 1,
column_header: false,
row_header: false,
row_section: false,
}]);
doc.push(Node::Prov {
page_no: 1,
bbox: [0.0, 0.0, 10.0, 8.0],
charspan: [0, 0],
seq: None,
inner: Box::new(Node::Table(table)),
});
doc.push(Node::Prov {
page_no: 7,
bbox: [-5.0, 0.0, 50.0, 50.0],
charspan: [0, 1],
seq: None,
inner: Box::new(Node::Paragraph { text: "y".into() }),
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(
v["texts"][0]["prov"][0]["bbox"],
serde_json::json!({ "l": 0.0, "t": 2.0, "r": 10.0, "b": 8.0, "coord_origin": "TOPLEFT" })
);
let cell = &v["tables"][0]["data"]["table_cells"][0]["bbox"];
assert_eq!(
(cell["l"].as_f64(), cell["r"].as_f64(), cell["b"].as_f64()),
(Some(1.0), Some(10.0), Some(8.0))
);
assert_eq!(v["tables"][0]["data"]["grid"][0][0]["bbox"]["r"], 10.0);
assert_eq!(
v["texts"][1]["prov"][0]["bbox"]["r"], 50.0,
"page 7 is not described"
);
}
#[test]
fn misplaced_list_items_are_wrapped_like_docling_core() {
use crate::tree::{ItemTree, ListMeta, TreeKind};
let mut t = ItemTree::default();
let li = |txt: &str| TreeKind::Text {
label: "list_item".into(),
text: txt.into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: Some(ListMeta {
enumerated: false,
marker: "-".into(),
}),
};
let group = |label: &str, name: &str| TreeKind::Group {
label: label.into(),
name: name.into(),
};
t.add(None, None, li("A"));
t.add(None, None, li("B"));
t.add(
None,
None,
TreeKind::Text {
label: "text".into(),
text: "plain".into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: None,
},
);
let cell = t.add(None, None, group("unspecified", "cell"));
t.add(Some(cell), None, li("C"));
t.add(Some(cell), None, li("D"));
let list = t.add(None, None, group("list", "list"));
t.add(Some(list), None, li("E"));
let doc = DoclingDocument {
tree: Some(t),
..DoclingDocument::new("t")
};
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let refs = |x: &Value| -> Vec<String> {
x.as_array()
.unwrap()
.iter()
.map(|r| r["$ref"].as_str().unwrap().to_string())
.collect()
};
assert_eq!(
refs(&v["body"]["children"]),
["#/groups/4", "#/texts/0", "#/groups/0", "#/groups/1"]
);
assert_eq!(
refs(&v["groups"][0]["children"]),
["#/groups/3", "#/groups/2"]
);
for (g, name, kids) in [
(2, "group", vec!["#/texts/2"]),
(3, "group", vec!["#/texts/3"]),
(4, "group", vec!["#/texts/4", "#/texts/5"]),
(1, "list", vec!["#/texts/1"]),
] {
assert_eq!(v["groups"][g]["label"], "list");
assert_eq!(v["groups"][g]["name"], name);
assert_eq!(refs(&v["groups"][g]["children"]), kids);
}
let texts: Vec<&str> = v["texts"]
.as_array()
.unwrap()
.iter()
.map(|t| t["text"].as_str().unwrap())
.collect();
assert_eq!(texts, ["plain", "E", "D", "C", "A", "B"]);
assert_eq!(v["texts"][2]["parent"]["$ref"], "#/groups/2");
}
#[test]
fn a_deleted_child_is_not_written_as_an_empty_ref() {
use crate::tree::{ItemTree, TreeKind};
let mut t = ItemTree::default();
let text = |txt: &str| TreeKind::Text {
label: "text".into(),
text: txt.into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: None,
};
let g = t.add(
None,
None,
TreeKind::Group {
label: "unspecified".into(),
name: "g".into(),
},
);
let gone = t.add(Some(g), None, text(""));
t.add(Some(g), None, text("kept"));
t.items[gone].deleted = true; let doc = DoclingDocument {
tree: Some(t),
..DoclingDocument::new("t")
};
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(
v["groups"][0]["children"],
serde_json::json!([{"$ref": "#/texts/0"}])
);
}
#[test]
fn a_backend_item_tree_is_written_verbatim() {
use crate::tree::{Formatting, ItemTree, ListMeta, TreeKind};
let mut t = ItemTree::default();
let text = |label: &str, txt: &str| TreeKind::Text {
label: label.into(),
text: txt.into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: None,
};
let title = t.add(None, Some(ContentLayer::Furniture), text("title", "Page"));
let h = t.add(None, None, text("title", "Heading"));
let group = t.add(
Some(h),
None,
TreeKind::Group {
label: "inline".into(),
name: "group".into(),
},
);
t.add(
Some(group),
None,
TreeKind::Text {
label: "text".into(),
text: "bold".into(),
orig: None,
formatting: Some(Formatting {
bold: true,
..Formatting::default()
}),
hyperlink: Some("https://example.com/".into()),
level: None,
list: None,
},
);
t.add(
Some(group),
None,
TreeKind::Code {
text: "x = 1".into(),
orig: None,
language: Some("python".into()),
formatting: None,
hyperlink: None,
},
);
let sub = t.add(
Some(h),
None,
TreeKind::Text {
label: "section_header".into(),
text: "Sub".into(),
orig: Some("Sub\u{2019}".into()),
formatting: None,
hyperlink: None,
level: Some(1),
list: None,
},
);
let item = t.add(
Some(sub),
None,
TreeKind::Text {
label: "list_item".into(),
text: "item".into(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: Some(ListMeta {
enumerated: true,
marker: "3.".into(),
}),
},
);
let _region = t.add(
Some(sub),
None,
TreeKind::FieldRegion {
items: vec![crate::FieldItem {
marker: None,
key: Some("Name".into()),
value: Some("Duck".into()),
value_kind: Some("read_only".into()),
}],
},
);
let table = t.add(
Some(sub),
None,
TreeKind::Table {
table: Table {
rows: vec![vec!["a \n<".into(), "b".into()]],
cells: Some(vec![
crate::TableCell {
text: "a \n<".into(),
bbox: None,
start_row: 0,
start_col: 0,
row_span: 3,
col_span: 1,
column_header: false,
row_header: true,
row_section: false,
},
crate::TableCell {
text: "b".into(),
bbox: None,
start_row: 0,
start_col: 1,
row_span: 1,
col_span: 1,
column_header: false,
row_header: false,
row_section: false,
},
]),
..Table::default()
},
rich_cells: vec![(0, 1, 0)], captions: Vec::new(),
},
);
let cell_group = t.add(
Some(table),
None,
TreeKind::Group {
label: "unspecified".into(),
name: "rich_cell_group_1_0_0".into(),
},
);
if let TreeKind::Table { rich_cells, .. } = &mut t.items[table].kind {
*rich_cells = vec![(0, 1, cell_group)];
}
let after = t.add(Some(sub), None, text("text", "after the region"));
let _ = (title, after);
let list = t.add(
Some(sub),
None,
TreeKind::Group {
label: "list".into(),
name: "list".into(),
},
);
t.reparent(item, Some(list));
let doc = DoclingDocument {
tree: Some(t),
..DoclingDocument::new("t")
};
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let texts: Vec<&str> = v["texts"]
.as_array()
.unwrap()
.iter()
.map(|t| t["text"].as_str().unwrap())
.collect();
assert_eq!(
texts,
[
"Page",
"Heading",
"bold",
"x = 1",
"Sub",
"item",
"Name",
"Duck",
"after the region"
]
);
assert_eq!(
v["body"]["children"],
serde_json::json!([{"$ref": "#/texts/0"}, {"$ref": "#/texts/1"}])
);
assert_eq!(v["texts"][0]["content_layer"], "furniture");
assert_eq!(
v["texts"][1]["children"],
serde_json::json!([{"$ref": "#/groups/0"}, {"$ref": "#/texts/4"}])
);
let bold = &v["texts"][2];
assert_eq!(bold["parent"]["$ref"], "#/groups/0");
let keys: Vec<&str> = bold
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
[
"self_ref",
"parent",
"children",
"content_layer",
"label",
"prov",
"orig",
"text",
"formatting",
"hyperlink"
]
);
assert_eq!(
bold["formatting"],
serde_json::json!({"bold": true, "italic": false, "underline": false, "strikethrough": false, "script": "baseline"})
);
let code = &v["texts"][3];
assert_eq!(code["label"], "code");
assert_eq!(code["code_language"], "Python");
let sub = &v["texts"][4];
assert_eq!(sub["orig"], "Sub\u{2019}");
assert_eq!(sub["level"], 1);
let item = &v["texts"][5];
let keys: Vec<&str> = item
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
keys,
[
"self_ref",
"parent",
"children",
"content_layer",
"label",
"prov",
"orig",
"text",
"enumerated",
"marker"
]
);
assert_eq!(item["marker"], "3.");
assert_eq!(v["texts"][7]["kind"], "read_only");
assert_eq!(v["field_regions"][0]["parent"]["$ref"], "#/texts/4");
let table = &v["tables"][0];
assert_eq!(
table["children"],
serde_json::json!([{"$ref": "#/groups/1"}])
);
let cells = table["data"]["table_cells"].as_array().unwrap();
assert_eq!(
cells[0]["text"], "a \n<",
"raw cell text is written verbatim"
);
assert_eq!(
cells[0]["end_row_offset_idx"], 3,
"declared spans are not clamped"
);
assert_eq!(cells[1]["ref"], serde_json::json!({"$ref": "#/groups/1"}));
assert!(cells[0].get("ref").is_none());
assert!(
table["data"]["grid"][0][1].get("ref").is_none(),
"the grid shows plain cells"
);
assert_eq!(v["groups"][1]["name"], "rich_cell_group_1_0_0");
}
#[test]
fn a_comment_section_can_be_referenced_by_its_note_text() {
let doc = DoclingDocument {
name: "c".into(),
nodes: vec![
Node::Commented {
comments: vec![0],
inner: Box::new(Node::Paragraph {
text: "annotated".into(),
}),
},
Node::CommentSection {
name: "comment-Sheet1-A1".into(),
text: "[author: A]: note".into(),
refs_note_text: true,
grouped: true,
},
],
..DoclingDocument::new("c")
};
let v = crate::json::to_json(&doc);
assert_eq!(v["groups"][0]["name"], "comment-Sheet1-A1");
assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/texts/1");
}
#[test]
fn comment_sections_link_back_to_their_items() {
let doc = DoclingDocument {
name: "c".into(),
nodes: vec![
Node::Commented {
comments: vec![0],
inner: Box::new(Node::Paragraph {
text: "annotated".into(),
}),
},
Node::Paragraph {
text: "plain".into(),
},
Node::CommentSection {
name: "comment-7".into(),
text: "[time: t]: note".into(),
refs_note_text: false,
grouped: true,
},
],
..DoclingDocument::new("c")
};
let v = crate::json::to_json(&doc);
assert_eq!(v["groups"][0]["label"], "comment_section");
assert_eq!(v["groups"][0]["name"], "comment-7");
assert_eq!(v["groups"][0]["content_layer"], "notes");
assert_eq!(v["groups"][0]["children"][0]["$ref"], "#/texts/2");
assert_eq!(v["texts"][2]["content_layer"], "notes");
assert_eq!(v["texts"][0]["comments"][0]["$ref"], "#/groups/0");
assert!(v["texts"][1].get("comments").is_none());
let keys: Vec<&str> = v["texts"][0]
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
assert_eq!(
&keys[keys.len() - 4..],
&["prov", "comments", "orig", "text"]
);
}
fn picture(caption: &str, caption_parent: CaptionParent) -> Node {
Node::Picture {
caption: Some(caption.into()),
caption_href: None,
image: None,
classification: None,
description: None,
caption_parent,
caption_location: None,
}
}
fn group(children: Vec<Node>) -> Node {
Node::Group {
label: "section".into(),
name: None,
layer: None,
children,
}
}
fn refs(v: &Value) -> Vec<&str> {
v.as_array()
.unwrap()
.iter()
.map(|r| r["$ref"].as_str().unwrap())
.collect()
}
#[test]
fn a_body_caption_follows_the_enclosing_top_level_item() {
let mut doc = DoclingDocument::new("t");
doc.push(picture("top", CaptionParent::Body));
doc.push(group(vec![
Node::Paragraph { text: "p".into() },
picture("nested", CaptionParent::Body),
]));
doc.push(Node::Paragraph {
text: "after".into(),
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(
refs(&v["body"]["children"]),
[
"#/texts/0",
"#/pictures/0",
"#/groups/0",
"#/texts/2",
"#/texts/3"
]
);
assert_eq!(
refs(&v["groups"][0]["children"]),
["#/texts/1", "#/pictures/1"]
);
for (cap, pic) in [(0, 0), (2, 1)] {
assert_eq!(v["texts"][cap]["label"], "caption");
assert_eq!(v["texts"][cap]["parent"]["$ref"], "#/body");
assert_eq!(
refs(&v["pictures"][pic]["captions"]),
[format!("#/texts/{cap}")]
);
assert_eq!(v["pictures"][pic]["children"], serde_json::json!([]));
}
}
#[test]
fn an_item_caption_is_the_items_first_child() {
let mut doc = DoclingDocument::new("t");
doc.push(picture("fig", CaptionParent::Item));
doc.push(Node::Table(Table {
rows: vec![vec!["a".into()]],
caption: Some("tab".into()),
caption_parent: CaptionParent::Item,
..Table::default()
}));
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/tables/0"]);
assert_eq!(v["texts"][0]["parent"]["$ref"], "#/pictures/0");
assert_eq!(refs(&v["pictures"][0]["children"]), ["#/texts/0"]);
assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
assert_eq!(v["texts"][1]["parent"]["$ref"], "#/tables/0");
assert_eq!(refs(&v["tables"][0]["children"]), ["#/texts/1"]);
assert_eq!(refs(&v["tables"][0]["captions"]), ["#/texts/1"]);
}
#[test]
fn picture_children_hang_off_the_picture_after_its_caption() {
let mut doc = DoclingDocument::new("t");
doc.push(picture("fig", CaptionParent::Item));
doc.push(Node::PictureChildren(vec![
Node::Heading {
level: 2,
text: "in-figure title".into(),
},
Node::Paragraph {
text: "axis label".into(),
},
Node::ListItem {
ordered: false,
number: 0,
first_in_list: true,
text: "callout".into(),
level: 0,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
},
]));
doc.push(Node::Paragraph {
text: "after".into(),
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(refs(&v["body"]["children"]), ["#/pictures/0", "#/texts/4"]);
assert_eq!(
refs(&v["pictures"][0]["children"]),
["#/texts/0", "#/texts/1", "#/texts/2", "#/groups/0"]
);
assert_eq!(refs(&v["pictures"][0]["captions"]), ["#/texts/0"]);
assert_eq!(v["texts"][1]["label"], "section_header");
assert_eq!(v["texts"][2]["label"], "text");
for t in 1..=2 {
assert_eq!(v["texts"][t]["parent"]["$ref"], "#/pictures/0");
assert_eq!(v["texts"][t]["content_layer"], "body");
}
assert_eq!(v["groups"][0]["parent"]["$ref"], "#/pictures/0");
assert_eq!(v["texts"][3]["parent"]["$ref"], "#/groups/0");
let md = doc.export_to_markdown();
assert!(
!md.contains("axis label") && !md.contains("callout"),
"{md}"
);
assert!(md.contains("after"), "{md}");
}
#[test]
fn a_container_caption_is_the_items_sibling() {
let mut doc = DoclingDocument::new("t");
doc.push(group(vec![
picture("chart", CaptionParent::Container),
Node::Table(Table {
rows: vec![vec!["a".into()]],
caption: Some("figcaption".into()),
caption_parent: CaptionParent::ContainerAfter,
..Table::default()
}),
]));
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
assert_eq!(refs(&v["body"]["children"]), ["#/groups/0"]);
assert_eq!(
refs(&v["groups"][0]["children"]),
["#/texts/0", "#/pictures/0", "#/tables/0", "#/texts/1"]
);
assert_eq!(v["texts"][0]["parent"]["$ref"], "#/groups/0");
assert_eq!(v["texts"][1]["parent"]["$ref"], "#/groups/0");
assert_eq!(v["pictures"][0]["children"], serde_json::json!([]));
assert_eq!(v["tables"][0]["children"], serde_json::json!([]));
}
#[test]
fn a_labeled_text_keeps_its_label_and_hyperlink_out_of_the_text() {
let mut doc = DoclingDocument::new("t");
doc.push(Node::LabeledText {
label: "footnote".into(),
text: "1 https://example.com/a\\_b".into(),
href: Some("https://example.com/a_b".into()),
});
doc.push(Node::LabeledText {
label: "footnote".into(),
text: "© 2022 the authors".into(),
href: None,
});
let v: Value = serde_json::from_str(&doc.export_to_json()).unwrap();
let t = &v["texts"][0];
assert_eq!(t["label"], "footnote");
assert_eq!(t["text"], "1 https://example.com/a_b");
assert_eq!(t["orig"], "1 https://example.com/a_b");
assert_eq!(t["hyperlink"], "https://example.com/a_b");
assert_eq!(v["texts"][1]["label"], "footnote");
assert!(v["texts"][1].get("hyperlink").is_none());
assert_eq!(
doc.export_to_markdown(),
"[1 https://example.com/a\\_b](https://example.com/a_b)\n\n© 2022 the authors\n"
);
assert_eq!(
doc.export_to_text(),
"1 https://example.com/a_b\n\n© 2022 the authors"
);
}
}