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::Parse(format!("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, 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, 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);
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,
}),
"list_item" => doc.push(Node::ListItem {
ordered: item["enumerated"].as_bool().unwrap_or(false),
number: 1,
first_in_list: true,
text,
level,
}),
"caption" => {} _ => 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);
}
}
}
}
fn list_group(children: &[Value], root: &Value, level: u8, doc: &mut DoclingDocument) {
let mut number = 0u64;
let mut first = true;
for c in children {
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;
}
number += 1;
doc.push(Node::ListItem {
ordered: item["enumerated"].as_bool().unwrap_or(false),
number,
first_in_list: first,
text: formatted_text(item),
level,
});
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();
if let Some(grid) = item["data"]["grid"].as_array() {
for row in grid {
if let Some(cells) = row.as_array() {
rows.push(
cells
.iter()
.map(|cell| cell["text"].as_str().unwrap_or("").to_string())
.collect::<Vec<_>>(),
);
}
}
}
if !rows.is_empty() {
doc.push(Node::Table(Table { rows }));
}
push_captions(item, root, doc);
}
fn picture_item(item: &Value, root: &Value, doc: &mut DoclingDocument) {
doc.push(Node::Picture {
caption: None,
image: None,
});
push_captions(item, root, doc);
}
fn push_captions(item: &Value, root: &Value, doc: &mut DoclingDocument) {
if let Some(caps) = item["captions"].as_array() {
for c in caps {
let t = text_of(c, root);
if !t.is_empty() {
doc.push(Node::Paragraph { text: t });
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
#[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}"
);
}
}