use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use roxmltree::{Document, Node as XmlNode, ParsingOptions};
use super::xbrl_dts::{effective, Arc, Taxonomy, LINK_NS, XLINK_NS};
use crate::backend::markdown::escape_text;
use crate::backend::{DeclarativeBackend, NoFetch};
use crate::error::ConversionError;
use crate::source::SourceDocument;
use docling_core::tree::{ItemTree, TreeKind};
use docling_core::{DoclingDocument, GraphCell, GraphLink, Node};
const XBRLI_NS: &str = "http://www.xbrl.org/2003/instance";
pub struct XbrlBackend;
impl DeclarativeBackend for XbrlBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
convert_xbrl(source, None)
}
}
pub(crate) fn convert_xbrl(
source: &SourceDocument,
taxonomy: Option<&Path>,
) -> Result<DoclingDocument, ConversionError> {
let xml = source.text()?;
super::xml_depth::check(&xml, "xbrl")?;
let opts = ParsingOptions {
allow_dtd: true,
..Default::default()
};
let dom = Document::parse_with_options(&xml, opts)
.map_err(|e| ConversionError::with_source("xbrl", e))?;
let root = dom.root_element();
let mut doc = DoclingDocument::new(&source.name);
let mut blocks: Vec<Option<ItemTree>> = Vec::new();
let taxonomy_dir: Option<PathBuf> = taxonomy.map(Path::to_path_buf).or_else(|| {
source
.path
.as_deref()
.and_then(Path::parent)
.map(Path::to_path_buf)
});
let schema_refs: Vec<&str> = root
.children()
.filter(|n| n.tag_name().namespace() == Some(LINK_NS) && n.tag_name().name() == "schemaRef")
.filter_map(|n| n.attribute((XLINK_NS, "href")))
.collect();
let taxonomy = Taxonomy::discover(taxonomy_dir.as_deref(), &schema_refs);
let facts: Vec<XmlNode> = root
.children()
.filter(|n| {
n.is_element() && !matches!(n.tag_name().namespace(), Some(XBRLI_NS) | Some(LINK_NS))
})
.collect();
let contexts = periods(root);
let units = unit_measures(root);
let (mut doc_type, mut doc_org, mut doc_period) = ("", "", "");
for fact in &facts {
let value = fact_value(*fact);
if value.is_empty() {
continue;
}
match fact.tag_name().name() {
"DocumentType" => doc_type = value,
"EntityRegistrantName" => doc_org = value,
"DocumentPeriodEndDate" => doc_period = value,
_ => {}
}
}
let mut title = format!("{doc_type} {doc_org} {doc_period}")
.trim()
.to_string();
if title.is_empty() {
title = source.name.clone();
}
doc.push(Node::Heading {
level: 1,
text: escape_text(&title),
});
let mut graph = Graph::default();
for fact in &facts {
let value = fact_value(*fact);
if value.is_empty() {
continue;
}
let namespace = fact.tag_name().namespace().unwrap_or("");
let local = fact.tag_name().name();
let concept = taxonomy.concept(namespace, local);
let html = value.split_whitespace().collect::<Vec<_>>().join(" ");
let is_text_block = match concept {
Some(c) => c.type_name == "textBlockItemType",
None => local.ends_with("TextBlock") || html.starts_with('<'),
};
if is_text_block {
let block = super::html::convert_html_with("text_block", &html, &NoFetch, false);
doc.nodes.extend(block.nodes);
blocks.push(block.tree);
}
if fact.attribute("unitRef").is_some() {
let qname = match fact.lookup_prefix(namespace) {
Some(prefix) => format!("{prefix}:{local}"),
None => concept.map_or_else(|| local.to_string(), |c| c.qname()),
};
let period = fact
.attribute("contextRef")
.and_then(|id| contexts.get(id))
.map_or("", String::as_str);
let unit = fact
.attribute("unitRef")
.and_then(|id| units.get(id))
.map_or("", String::as_str);
let decimals = fact.attribute("decimals").unwrap_or("");
graph.add_fact(local, &qname, value, period, unit, decimals);
}
}
let presentation = effective(&taxonomy.presentation);
let mut parent_of: HashMap<&str, &Arc> = HashMap::new();
for arc in &presentation {
parent_of.entry(arc.to.as_str()).or_insert(arc);
}
let mut visited: HashSet<String> = HashSet::new();
for fact in &facts {
if fact.attribute("unitRef").is_none() || fact_value(*fact).is_empty() {
continue;
}
let namespace = fact.tag_name().namespace().unwrap_or("");
let local = fact.tag_name().name();
let concept = taxonomy.concept(namespace, local);
let fact_qname = match fact.lookup_prefix(namespace) {
Some(prefix) => format!("{prefix}:{local}"),
None => concept.map_or_else(|| local.to_string(), |c| c.qname()),
};
if !visited.insert(fact_qname.clone()) {
continue;
}
let concept_qname = concept.map_or_else(|| fact_qname.clone(), |c| c.qname());
if let Some(fact_cells) = graph.fact_cells.get(&fact_qname).cloned() {
let concept_cell = graph.hierarchy_cell(&concept_qname);
for fact_cell in fact_cells {
if fact_cell != concept_cell {
graph.add_link("to_child", concept_cell, fact_cell);
}
}
}
let mut current = concept_qname;
while let Some(arc) = parent_of.get(current.as_str()) {
let parent = arc.from.clone();
let child_cell = graph.hierarchy_cell(¤t);
let parent_cell = graph.hierarchy_cell(&parent);
graph.add_link("to_child", parent_cell, child_cell);
if !visited.insert(parent.clone()) {
break;
}
current = parent;
}
}
for arc in effective(&taxonomy.calculation) {
let parent_cell = graph.hierarchy_cell(&arc.from);
let child_cell = graph.hierarchy_cell(&arc.to);
graph.add_link("to_child", parent_cell, child_cell);
let weight = arc
.weight
.as_deref()
.and_then(|w| w.trim().parse::<f64>().ok())
.unwrap_or(1.0);
let weight_cell = graph.push_cell("value", format!("weight: {weight:?}"), "weight".into());
graph.add_link("to_value", child_cell, weight_cell);
}
let graph = (!graph.cells.is_empty() && !graph.links.is_empty()).then(|| {
doc.push(Node::KeyValueGraph {
cells: graph.cells.clone(),
links: graph.links.clone(),
});
(graph.cells, graph.links)
});
if blocks.iter().all(Option::is_some) {
let (tree, redirects) = assemble_tree(title, blocks.into_iter().flatten().collect(), graph);
redirect_cell_text(&mut doc.nodes, &redirects);
doc.tree = Some(tree);
}
Ok(doc)
}
struct Redirect {
table: usize,
cell: (usize, usize),
shows: (usize, usize),
}
fn redirect_cell_text(nodes: &mut [Node], redirects: &[Redirect]) {
if redirects.is_empty() {
return;
}
fn tables<'a>(nodes: &'a mut [Node], out: &mut Vec<&'a mut docling_core::Table>) {
for node in nodes {
match node {
Node::Table(table) => out.push(table),
Node::Group { children, .. } => tables(children, out),
_ => {}
}
}
}
let mut found = Vec::new();
tables(nodes, &mut found);
let mut by_table: HashMap<usize, Vec<&Redirect>> = HashMap::new();
for r in redirects {
by_table.entry(r.table).or_default().push(r);
}
for (index, table) in found.into_iter().enumerate() {
let Some(list) = by_table.get(&index) else {
continue;
};
let before = table.rows.clone();
for r in list {
let text = before.get(r.shows.0).and_then(|row| row.get(r.shows.1));
let slot = table
.rows
.get_mut(r.cell.0)
.and_then(|row| row.get_mut(r.cell.1));
if let (Some(text), Some(slot)) = (text, slot) {
*slot = text.clone();
}
}
}
}
fn assemble_tree(
title: String,
blocks: Vec<ItemTree>,
graph: Option<(Vec<GraphCell>, Vec<GraphLink>)>,
) -> (ItemTree, Vec<Redirect>) {
let mut tree = ItemTree::default();
tree.add(
None,
None,
TreeKind::Text {
label: "title".into(),
text: title,
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: None,
},
);
let mut block_group_index: Vec<Option<usize>> = vec![None];
for block in blocks {
for (id, item) in block.items.iter().enumerate() {
let is_group = !item.deleted && matches!(item.kind, TreeKind::Group { .. });
block_group_index.push(is_group.then(|| block.bucket_index(id)));
}
tree.append(block);
}
if let Some((cells, links)) = graph {
tree.add(None, None, TreeKind::KeyValueGraph { cells, links });
block_group_index.push(None);
}
let new_of = tree.renumber_in_traversal_order();
let mut old_index: Vec<Option<usize>> = vec![None; tree.items.len()];
for (old, new) in new_of.iter().enumerate() {
if let Some(new) = new {
old_index[*new] = block_group_index[old];
}
}
let groups: Vec<usize> = (0..tree.items.len())
.filter(|&id| matches!(tree.items[id].kind, TreeKind::Group { .. }))
.collect();
let mut redirects = Vec::new();
let mut table_index = 0;
for table in 0..tree.items.len() {
let TreeKind::Table { rich_cells, .. } = &tree.items[table].kind else {
continue;
};
table_index += 1;
if rich_cells.is_empty() {
continue;
}
let owners: Vec<(usize, usize, usize)> = rich_cells.clone();
let mut refs: Vec<Option<usize>> =
rich_cells.iter().map(|&(_, _, g)| old_index[g]).collect();
for child in tree.items[table].children.clone() {
let (Some(old), Ok(new)) = (old_index[child], groups.binary_search(&child)) else {
continue;
};
if let Some(pos) = refs.iter().position(|r| *r == Some(old)) {
refs[pos] = Some(new);
}
}
let TreeKind::Table { rich_cells, .. } = &mut tree.items[table].kind else {
unreachable!("checked above");
};
for (cell, target) in rich_cells.iter_mut().zip(refs) {
if let Some(&group) = target.and_then(|i| groups.get(i)) {
cell.2 = group;
}
}
for &(row, col, group) in rich_cells.iter() {
if let Some(&(r, c, _)) = owners
.iter()
.find(|o| o.2 == group && (o.0, o.1) != (row, col))
{
redirects.push(Redirect {
table: table_index - 1,
cell: (row, col),
shows: (r, c),
});
}
}
}
(tree, redirects)
}
fn fact_value<'a>(fact: XmlNode<'a, 'a>) -> &'a str {
fact.text().map_or("", str::trim)
}
#[derive(Default)]
struct Graph {
cells: Vec<GraphCell>,
links: Vec<GraphLink>,
fact_cells: HashMap<String, Vec<usize>>,
hierarchy_cells: HashMap<String, usize>,
created_links: HashSet<(usize, usize)>,
}
impl Graph {
fn push_cell(&mut self, label: &str, text: String, orig: String) -> usize {
let cell_id = self.cells.len();
self.cells.push(GraphCell {
label: label.into(),
cell_id,
text,
orig,
});
cell_id
}
fn add_fact(
&mut self,
local: &str,
qname: &str,
value: &str,
period: &str,
unit: &str,
decimals: &str,
) {
let key = self.push_cell("key", local.into(), qname.into());
self.fact_cells.entry(qname.into()).or_default().push(key);
let labelled = |prefix: &str, text: &str| {
if text.is_empty() {
String::new()
} else {
format!("{prefix}: {text}")
}
};
for (text, orig) in [
(labelled("value", value), "value"),
(labelled("period", period), "period"),
(labelled("currency", unit), "unit"),
(labelled("decimals", decimals), "decimals"),
] {
let cell = self.push_cell("value", text, orig.into());
self.links.push(GraphLink {
label: "to_value".into(),
source_cell_id: key,
target_cell_id: cell,
});
}
}
fn hierarchy_cell(&mut self, qname: &str) -> usize {
if let Some(&cell) = self.hierarchy_cells.get(qname) {
return cell;
}
let local = qname.rsplit(':').next().unwrap_or(qname).to_string();
let cell = self.push_cell("key", local, qname.into());
self.hierarchy_cells.insert(qname.into(), cell);
cell
}
fn add_link(&mut self, label: &str, source: usize, target: usize) {
if self.created_links.insert((source, target)) {
self.links.push(GraphLink {
label: label.into(),
source_cell_id: source,
target_cell_id: target,
});
}
}
}
fn periods(root: XmlNode) -> HashMap<String, String> {
let mut out = HashMap::new();
for context in root.children().filter(|n| is_xbrli(*n, "context")) {
let Some(id) = context.attribute("id") else {
continue;
};
let Some(period) = context.children().find(|n| is_xbrli(*n, "period")) else {
continue;
};
let child_text = |name: &str| {
period
.children()
.find(|n| is_xbrli(*n, name))
.and_then(|n| n.text())
.map(str::trim)
};
let text = if let Some(instant) = child_text("instant") {
end_of_day(instant)
} else if let (Some(start), Some(end)) = (child_text("startDate"), child_text("endDate")) {
format!("{} - {}", date_part(start), end_of_day(end))
} else {
String::new()
};
out.insert(id.to_string(), text);
}
out
}
fn unit_measures(root: XmlNode) -> HashMap<String, String> {
root.children()
.filter(|n| is_xbrli(*n, "unit"))
.filter_map(|unit| {
let id = unit.attribute("id")?;
let measure = unit
.descendants()
.find(|n| is_xbrli(*n, "measure"))
.and_then(|n| n.text())
.map(str::trim)?;
let local = measure.rsplit(':').next().unwrap_or(measure);
Some((id.to_string(), local.to_string()))
})
.collect()
}
fn is_xbrli(node: XmlNode, name: &str) -> bool {
node.is_element()
&& node.tag_name().namespace() == Some(XBRLI_NS)
&& node.tag_name().name() == name
}
fn date_part(text: &str) -> &str {
text.split('T').next().unwrap_or(text)
}
fn end_of_day(text: &str) -> String {
if text.contains('T') {
return date_part(text).to_string();
}
let mut parts = text.splitn(3, '-');
let parsed = (
parts.next().and_then(|y| y.parse::<i64>().ok()),
parts.next().and_then(|m| m.parse::<u32>().ok()),
parts.next().and_then(|d| d.parse::<u32>().ok()),
);
let (Some(year), Some(month), Some(day)) = parsed else {
return text.to_string();
};
let (y, m, d) = next_day(year, month, day);
format!("{y:04}-{m:02}-{d:02}")
}
fn next_day(year: i64, month: u32, day: u32) -> (i64, u32, u32) {
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
let days_in_month = match month {
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
4 | 6 | 9 | 11 => 30,
2 if leap => 29,
2 => 28,
_ => return (year, month, day),
};
if day < days_in_month {
(year, month, day + 1)
} else if month < 12 {
(year, month + 1, 1)
} else {
(year + 1, 1, 1)
}
}
pub fn looks_like_xbrl(head: &str) -> bool {
head.contains("us-gaap")
|| head.contains("xbrli:")
|| head.contains("dei:DocumentType")
|| head.contains("http://www.xbrl.org")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::InputFormat;
const INSTANCE: &str = r#"<xbrli:xbrl xmlns:xbrli="http://www.xbrl.org/2003/instance"
xmlns:dei="d" xmlns:us-gaap="u" xmlns:iso4217="http://www.xbrl.org/2003/iso4217">
<xbrli:context id="c0"><xbrli:entity><xbrli:identifier scheme="s">1</xbrli:identifier></xbrli:entity>
<xbrli:period><xbrli:startDate>2025-01-01</xbrli:startDate><xbrli:endDate>2025-12-31</xbrli:endDate></xbrli:period></xbrli:context>
<xbrli:context id="c1"><xbrli:entity><xbrli:identifier scheme="s">1</xbrli:identifier></xbrli:entity>
<xbrli:period><xbrli:instant>2024-02-29</xbrli:instant></xbrli:period></xbrli:context>
<xbrli:unit id="usd"><xbrli:measure>iso4217:USD</xbrli:measure></xbrli:unit>
<dei:DocumentType contextRef="c0">10-Q</dei:DocumentType>
<dei:EntityRegistrantName contextRef="c0">Acme Inc.</dei:EntityRegistrantName>
<dei:DocumentPeriodEndDate contextRef="c0">2025-12-31</dei:DocumentPeriodEndDate>
<us-gaap:NatureOfOperationsTextBlock contextRef="c0"><p><b>NOTE 1</b></p><p>Body.</p></us-gaap:NatureOfOperationsTextBlock>
<us-gaap:NatureOfOperationsTextBlock contextRef="c0"><p><b>NOTE 1</b></p><p>Body.</p></us-gaap:NatureOfOperationsTextBlock>
<us-gaap:Assets contextRef="c1" unitRef="usd" decimals="-3">1000</us-gaap:Assets>
<us-gaap:Revenues contextRef="c0" unitRef="usd">50</us-gaap:Revenues>
<us-gaap:Nil contextRef="c0" unitRef="usd" xsi:nil="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"/>
</xbrli:xbrl>"#;
fn convert(xml: &str) -> DoclingDocument {
let src = SourceDocument::from_bytes("x", InputFormat::XmlXbrl, xml.as_bytes().to_vec());
XbrlBackend.convert(&src).unwrap()
}
#[test]
fn title_text_blocks_and_placeholder_in_markdown() {
let md = convert(INSTANCE).export_to_markdown();
assert_eq!(
md.trim_end(),
"# 10-Q Acme Inc. 2025-12-31\n\n**NOTE 1**\n\nBody.\n\n**NOTE 1**\n\nBody.\n\n<!-- missing-key-value-item -->",
"got:\n{md:?}"
);
}
#[test]
fn numeric_facts_become_the_key_value_graph() {
let doc = convert(INSTANCE);
let json = doc.export_to_json_value();
let kv = &json["key_value_items"][0];
assert_eq!(kv["self_ref"], "#/key_value_items/0");
assert_eq!(kv["label"], "key_value_region");
assert_eq!(
json["body"]["children"].as_array().unwrap().last().unwrap()["$ref"],
"#/key_value_items/0"
);
let cells = kv["graph"]["cells"].as_array().unwrap();
assert_eq!(cells.len(), 12, "{cells:#?}");
let texts: Vec<&str> = cells.iter().map(|c| c["text"].as_str().unwrap()).collect();
assert_eq!(
&texts[..10],
&[
"Assets",
"value: 1000",
"period: 2024-03-01",
"currency: USD",
"decimals: -3",
"Revenues",
"value: 50",
"period: 2025-01-01 - 2026-01-01",
"currency: USD",
""
]
);
assert_eq!(cells[0]["orig"], "us-gaap:Assets");
let links = kv["graph"]["links"].as_array().unwrap();
assert_eq!(
links[0],
serde_json::json!({"label": "to_value", "source_cell_id": 0, "target_cell_id": 1})
);
assert!(links.iter().any(|l| l["label"] == "to_child"
&& l["source_cell_id"] == 10
&& l["target_cell_id"] == 0));
}
#[test]
fn a_date_only_period_end_is_read_as_the_next_day() {
assert_eq!(end_of_day("2024-02-29"), "2024-03-01");
assert_eq!(end_of_day("2025-12-31"), "2026-01-01");
assert_eq!(end_of_day("2025-06-30"), "2025-07-01");
assert_eq!(end_of_day("2025-06-30T00:00:00"), "2025-06-30");
}
}