use serde_json::Value;
use crate::backend::markdown::escape_text;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
use docling_core::{DoclingDocument, Node, Table};
pub struct DoclingJsonBackend;
impl DeclarativeBackend for DoclingJsonBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let root: Value = serde_json::from_str(source.text()?)
.map_err(|e| ConversionError::with_source("docling-json", e))?;
let name = root["name"].as_str().unwrap_or(&source.name).to_string();
let mut doc = DoclingDocument::new(name);
if let Some(children) = root["body"]["children"].as_array() {
for c in children {
walk(c, &root, 0, &mut doc);
}
}
Ok(doc)
}
}
fn resolve<'a>(reference: &Value, root: &'a Value) -> Option<&'a Value> {
let path = reference["$ref"].as_str()?.strip_prefix("#/")?;
let (kind, idx) = path.rsplit_once('/')?;
root.get(kind)?.get(idx.parse::<usize>().ok()?)
}
fn ref_kind(reference: &Value) -> &str {
reference["$ref"].as_str().unwrap_or("")
}
fn text_of(reference: &Value, root: &Value) -> String {
resolve(reference, root)
.map(formatted_text)
.unwrap_or_default()
}
fn formatted_text(item: &Value) -> String {
let mut res = escape_text(item["text"].as_str().unwrap_or(""));
let fmt = &item["formatting"];
if fmt["bold"].as_bool() == Some(true) {
res = format!("**{res}**");
}
if fmt["italic"].as_bool() == Some(true) {
res = format!("*{res}*");
}
if fmt["strikethrough"].as_bool() == Some(true) {
res = format!("~~{res}~~");
}
if let Some(url) = item["hyperlink"].as_str() {
res = format!("[{res}]({url})");
}
res
}
fn walk(reference: &Value, root: &Value, level: u8, doc: &mut DoclingDocument) {
let Some(item) = resolve(reference, root) else {
return;
};
if item["content_layer"].as_str() == Some("furniture") {
return;
}
let kind = ref_kind(reference);
if kind.starts_with("#/texts/") {
text_item(item, root, level, doc);
if let Some(children) = item["children"].as_array() {
for c in children {
walk(c, root, level, doc);
}
}
} else if kind.starts_with("#/groups/") {
group_item(item, root, level, doc);
} else if kind.starts_with("#/tables/") {
table_item(item, root, doc);
} else if kind.starts_with("#/pictures/") {
picture_item(item, root, doc);
}
}
fn text_item(item: &Value, root: &Value, level: u8, doc: &mut DoclingDocument) {
let label = item["label"].as_str().unwrap_or("text");
if item["text"].as_str().unwrap_or("").is_empty() {
if label == "formula" {
doc.push(Node::Paragraph {
text: "<!-- formula-not-decoded -->".into(),
});
}
return;
}
let text = formatted_text(item);
let raw = || item["text"].as_str().unwrap_or("").to_string();
match label {
"title" => doc.push(Node::Heading { level: 1, text }),
"section_header" => {
let lvl = item["level"].as_u64().unwrap_or(1) as u8;
doc.push(Node::Heading {
level: lvl + 1,
text,
});
}
"code" => {
doc.push(Node::Code {
language: item["code_language"]
.as_str()
.filter(|s| !s.is_empty() && *s != "unknown")
.map(String::from),
text: raw(),
orig: None,
pretty: None,
});
if let Some(cap) = caption_of(item, root) {
doc.push(Node::Paragraph { text: cap });
}
}
"list_item" => doc.push(Node::ListItem {
ordered: item["enumerated"].as_bool().unwrap_or(false),
number: 1,
first_in_list: true,
text,
level,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
}),
"checkbox_selected" => doc.push(Node::CheckboxItem {
checked: true,
text,
}),
"checkbox_unselected" => doc.push(Node::CheckboxItem {
checked: false,
text,
}),
"formula" => doc.push(Node::Formula {
latex: raw(),
orig: item["orig"].as_str().unwrap_or("").to_string(),
location: None,
}),
"caption" => {
if !caption_is_claimed(item, root) {
doc.push(Node::Caption { text, href: None });
}
}
_ => doc.push(Node::Paragraph { text }), }
}
fn group_item(item: &Value, root: &Value, level: u8, doc: &mut DoclingDocument) {
let label = item["label"].as_str().unwrap_or("unspecified");
let empty = Vec::new();
let children = item["children"].as_array().unwrap_or(&empty);
match label {
"list" | "ordered_list" => list_group(children, root, level, doc),
"inline" => {
let joined = children
.iter()
.map(|c| text_of(c, root))
.collect::<Vec<_>>()
.join(" ");
if !joined.is_empty() {
doc.push(Node::Paragraph { text: joined });
}
}
_ => {
for c in children {
walk(c, root, level, doc);
}
}
}
}
enum Marker {
Verbatim(Option<u64>),
Bullet(Option<String>),
FromGroup,
}
fn marker_of(item: &Value) -> Marker {
let raw = item["marker"].as_str().unwrap_or("");
if raw.is_empty() {
return Marker::FromGroup;
}
if matches!(raw, "-" | "*" | "+") {
return Marker::Verbatim(None);
}
if let Some(digits) = raw.strip_suffix('.') {
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
return Marker::Verbatim(digits.parse().ok());
}
}
Marker::Bullet(
raw.chars()
.any(|c| c.is_ascii_alphanumeric())
.then(|| raw.to_string()),
)
}
fn list_group(children: &[Value], root: &Value, level: u8, doc: &mut DoclingDocument) {
let enumerated_group = children
.first()
.and_then(|c| resolve(c, root))
.is_some_and(|it| {
it["label"].as_str() == Some("list_item") && it["enumerated"].as_bool() == Some(true)
});
let mut first = true;
for (pos, c) in children.iter().enumerate() {
let kind = ref_kind(c);
if kind.starts_with("#/groups/") {
walk(c, root, level + 1, doc);
continue;
}
let Some(item) = resolve(c, root) else {
continue;
};
if item["label"].as_str() != Some("list_item") {
continue;
}
let position = pos as u64 + 1;
let body = formatted_text(item);
let (ordered, number, text) = match marker_of(item) {
Marker::Verbatim(Some(n)) => (true, n, body),
Marker::Verbatim(None) => (false, position, body),
Marker::Bullet(kept) => {
let text = match kept {
Some(m) if !body.is_empty() => format!("{m} {body}"),
Some(m) => m,
None => body,
};
(false, position, text)
}
Marker::FromGroup => (enumerated_group, position, body),
};
doc.push(Node::ListItem {
ordered,
number,
first_in_list: first,
text,
level,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
});
first = false;
if let Some(sub) = item["children"].as_array() {
for s in sub {
walk(s, root, level + 1, doc);
}
}
}
}
fn table_item(item: &Value, root: &Value, doc: &mut DoclingDocument) {
let mut rows = Vec::new();
let mut structure = docling_core::TableStructure::default();
if let Some(grid) = item["data"]["grid"].as_array() {
for (r, row) in grid.iter().enumerate() {
let Some(cells) = row.as_array() else {
continue;
};
rows.push(
cells
.iter()
.map(|cell| cell["text"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>(),
);
let past = |cell: &Value, key: &str, idx: usize| {
cell[key].as_u64().is_some_and(|v| (v as usize) < idx)
};
structure
.col_header
.push(flags(cells, |c| c["column_header"].as_bool() == Some(true)));
structure
.row_header
.push(flags(cells, |c| c["row_header"].as_bool() == Some(true)));
structure.col_continuation.push(flags(cells.as_slice(), {
let mut col = 0;
move |c| {
let cont = past(c, "start_col_offset_idx", col);
col += 1;
cont
}
}));
structure
.row_continuation
.push(flags(cells, |c| past(c, "start_row_offset_idx", r)));
}
}
if !rows.is_empty() {
let has_structure = structure.col_header.iter().flatten().any(|&b| b)
|| structure.row_header.iter().flatten().any(|&b| b)
|| structure.col_continuation.iter().flatten().any(|&b| b)
|| structure.row_continuation.iter().flatten().any(|&b| b);
doc.push(Node::Table(Table {
rows,
location: None,
structure: has_structure.then_some(structure),
cell_blocks: None,
cells: None,
caption: caption_of(item, root),
}));
}
}
fn flags(cells: &[Value], f: impl FnMut(&Value) -> bool) -> Vec<bool> {
cells.iter().map(f).collect()
}
fn picture_item(item: &Value, root: &Value, doc: &mut DoclingDocument) {
doc.push(Node::Picture {
caption: caption_of(item, root),
caption_href: None,
image: None,
classification: None,
});
}
fn caption_is_claimed(item: &Value, root: &Value) -> bool {
let Some(me) = item["self_ref"].as_str() else {
return false;
};
["tables", "pictures", "texts"].iter().any(|bucket| {
root[bucket].as_array().is_some_and(|items| {
items.iter().any(|it| {
it["captions"]
.as_array()
.is_some_and(|caps| caps.iter().any(|c| c["$ref"].as_str() == Some(me)))
})
})
})
}
fn caption_of(item: &Value, root: &Value) -> Option<String> {
let caps = item["captions"].as_array()?;
let joined = caps
.iter()
.map(|c| text_of(c, root))
.collect::<Vec<_>>()
.join(" ");
(!joined.trim().is_empty()).then_some(joined)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
fn md(json: &str) -> String {
DoclingJsonBackend
.convert(&SourceDocument::from_bytes(
"t.json",
InputFormat::JsonDocling,
json.as_bytes().to_vec(),
))
.unwrap()
.export_to_markdown()
}
#[test]
fn captions_lead_a_picture_and_a_table_but_trail_code() {
let json = r##"{
"name":"n","body":{"children":[{"$ref":"#/pictures/0"},{"$ref":"#/tables/0"},{"$ref":"#/texts/2"}]},
"texts":[
{"self_ref":"#/texts/0","label":"caption","text":"Figure 1: a duck","children":[]},
{"self_ref":"#/texts/1","label":"caption","text":"Table 1: the counts","children":[]},
{"self_ref":"#/texts/2","label":"code","text":"let x = 1;","children":[],
"captions":[{"$ref":"#/texts/3"}]},
{"self_ref":"#/texts/3","label":"caption","text":"Listing 1: a binding","children":[]}
],
"groups":[],
"tables":[{"self_ref":"#/tables/0","label":"table","captions":[{"$ref":"#/texts/1"}],
"data":{"grid":[[{"text":"a"}]]},"children":[]}],
"pictures":[{"self_ref":"#/pictures/0","label":"picture","captions":[{"$ref":"#/texts/0"}],"children":[]}]
}"##;
assert_eq!(
md(json),
"Figure 1: a duck\n\n<!-- image -->\n\nTable 1: the counts\n\n| a |\n|-----|\n\n```\nlet x = 1;\n```\n\nListing 1: a binding\n"
);
}
#[test]
fn an_unclaimed_caption_still_renders() {
let json = r##"{
"name":"n","body":{"children":[{"$ref":"#/texts/0"},{"$ref":"#/pictures/0"},{"$ref":"#/texts/1"}]},
"texts":[
{"self_ref":"#/texts/0","label":"caption","text":"Figure 6: nobody claims me","children":[]},
{"self_ref":"#/texts/1","label":"caption","text":"Figure 7: claimed","children":[]}
],
"groups":[],"tables":[],
"pictures":[{"self_ref":"#/pictures/0","label":"picture","captions":[{"$ref":"#/texts/1"}],"children":[]}]
}"##;
assert_eq!(
md(json),
"Figure 6: nobody claims me\n\nFigure 7: claimed\n\n<!-- image -->\n"
);
}
#[test]
fn a_numeric_marker_is_printed_verbatim() {
let json = r##"{
"name":"n","body":{"children":[{"$ref":"#/groups/0"}]},
"texts":[
{"self_ref":"#/texts/0","label":"list_item","text":"Xue, W.","marker":"18.","enumerated":true,"children":[]},
{"self_ref":"#/texts/1","label":"list_item","text":"Ye, J.","marker":"19.","enumerated":true,"children":[]}
],
"groups":[{"self_ref":"#/groups/0","label":"list","name":"list",
"children":[{"$ref":"#/texts/0"},{"$ref":"#/texts/1"}]}],
"tables":[],"pictures":[]
}"##;
assert_eq!(md(json), "18. Xue, W.\n19. Ye, J.\n");
}
#[test]
fn a_non_markdown_marker_forces_a_bullet_and_is_kept() {
let json = r##"{
"name":"n","body":{"children":[{"$ref":"#/groups/0"}]},
"texts":[
{"self_ref":"#/texts/0","label":"list_item","text":"Human Annotation","marker":"(1)","enumerated":true,"children":[]},
{"self_ref":"#/texts/1","label":"list_item","text":"Red - PDF cells","marker":"a.","enumerated":true,"children":[]},
{"self_ref":"#/texts/2","label":"list_item","text":"a stray glyph","marker":"\u0084","enumerated":false,"children":[]}
],
"groups":[{"self_ref":"#/groups/0","label":"list","name":"list",
"children":[{"$ref":"#/texts/0"},{"$ref":"#/texts/1"},{"$ref":"#/texts/2"}]}],
"tables":[],"pictures":[]
}"##;
assert_eq!(
md(json),
"- (1) Human Annotation\n- a. Red - PDF cells\n- a stray glyph\n"
);
}
#[test]
fn an_unmarked_item_is_numbered_by_its_position_in_the_group() {
let json = r##"{
"name":"n","body":{"children":[{"$ref":"#/groups/0"}]},
"texts":[
{"self_ref":"#/texts/0","label":"list_item","text":"one","marker":"","enumerated":true,"children":[]},
{"self_ref":"#/texts/1","label":"list_item","text":"nested","marker":"","enumerated":true,"children":[]},
{"self_ref":"#/texts/2","label":"list_item","text":"after","marker":"","enumerated":true,"children":[]}
],
"groups":[
{"self_ref":"#/groups/0","label":"list","name":"list",
"children":[{"$ref":"#/texts/0"},{"$ref":"#/groups/1"},{"$ref":"#/texts/2"}]},
{"self_ref":"#/groups/1","label":"list","name":"list","children":[{"$ref":"#/texts/1"}]}
],
"tables":[],"pictures":[]
}"##;
assert_eq!(md(json), "1. one\n 1. nested\n3. after\n");
}
#[test]
fn a_group_starting_on_a_bullet_stays_bulleted() {
let json = r##"{
"name":"n","body":{"children":[{"$ref":"#/groups/0"}]},
"texts":[
{"self_ref":"#/texts/0","label":"list_item","text":"bullet first","marker":"","enumerated":false,"children":[]},
{"self_ref":"#/texts/1","label":"list_item","text":"still a bullet","marker":"","enumerated":true,"children":[]}
],
"groups":[{"self_ref":"#/groups/0","label":"list","name":"list",
"children":[{"$ref":"#/texts/0"},{"$ref":"#/texts/1"}]}],
"tables":[],"pictures":[]
}"##;
assert_eq!(md(json), "- bullet first\n- still a bullet\n");
}
#[test]
fn checkboxes_render_and_code_is_not_escaped() {
let json = r##"{
"name":"n","body":{"children":[{"$ref":"#/texts/0"},{"$ref":"#/texts/1"},{"$ref":"#/texts/2"},{"$ref":"#/texts/3"}]},
"texts":[
{"self_ref":"#/texts/0","label":"checkbox_selected","text":"done","children":[]},
{"self_ref":"#/texts/1","label":"checkbox_unselected","text":"todo","children":[]},
{"self_ref":"#/texts/2","label":"code","text":"VERIFY_GROUP_FOR_USER ( SESSION_USER )","children":[]},
{"self_ref":"#/texts/3","label":"formula","text":"a_1 + b_2","orig":"a_1 + b_2","children":[]}
],
"groups":[],"tables":[],"pictures":[]
}"##;
assert_eq!(
md(json),
"- [x] done\n\n- [ ] todo\n\n```\nVERIFY_GROUP_FOR_USER ( SESSION_USER )\n```\n\n$$a_1 + b_2$$\n"
);
}
#[test]
fn walks_children_nested_under_headings() {
let json = r##"{
"name": "n", "body": {"children": [{"$ref":"#/texts/0"}]},
"texts": [
{"self_ref":"#/texts/0","label":"section_header","level":1,"text":"Intro",
"children":[{"$ref":"#/texts/1"},{"$ref":"#/texts/2"}]},
{"self_ref":"#/texts/1","label":"text","text":"First para","children":[]},
{"self_ref":"#/texts/2","label":"section_header","level":2,"text":"Sub",
"children":[{"$ref":"#/texts/3"}]},
{"self_ref":"#/texts/3","label":"text","text":"Deep para","children":[]}
],
"groups": [], "tables": [], "pictures": []
}"##;
let doc = DoclingJsonBackend
.convert(&SourceDocument::from_bytes(
"t.json",
InputFormat::JsonDocling,
json.as_bytes().to_vec(),
))
.unwrap();
assert_eq!(
doc.export_to_markdown(),
"## Intro\n\nFirst para\n\n### Sub\n\nDeep para\n"
);
}
#[test]
fn walks_body_tree_with_formatting_and_lists() {
let json = r##"{
"schema_name": "DoclingDocument", "name": "t",
"body": {"children": [{"$ref":"#/texts/0"},{"$ref":"#/texts/1"},{"$ref":"#/groups/0"}]},
"texts": [
{"self_ref":"#/texts/0","label":"title","text":"Doc"},
{"self_ref":"#/texts/1","label":"section_header","level":1,"text":"Sec","hyperlink":"http://x"},
{"self_ref":"#/texts/2","label":"list_item","text":"one","enumerated":false},
{"self_ref":"#/texts/3","label":"list_item","text":"two","enumerated":false,
"formatting":{"bold":true,"italic":false,"strikethrough":false}}
],
"groups": [{"self_ref":"#/groups/0","label":"list",
"children":[{"$ref":"#/texts/2"},{"$ref":"#/texts/3"}]}],
"tables": [], "pictures": []
}"##;
let src =
SourceDocument::from_bytes("t", InputFormat::JsonDocling, json.as_bytes().to_vec());
let md = DoclingJsonBackend
.convert(&src)
.unwrap()
.export_to_markdown();
assert!(
md.starts_with("# Doc\n\n## [Sec](http://x)\n\n- one\n- **two**"),
"got:\n{md}"
);
}
}