use std::collections::HashMap;
use docling_core::{DoclingDocument, InlineRun, ListItemDclx, Node, PictureImage, Script, Table};
use roxmltree::{Document, Node as XmlNode};
use crate::backend::markdown::escape_text;
use crate::backend::ooxml::{resolve, Package};
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
pub struct DocxBackend;
impl DeclarativeBackend for DocxBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let mut pkg = Package::open(&source.bytes)
.ok_or_else(|| ConversionError::Parse("docx: bad zip".into()))?;
let document = pkg
.read("word/document.xml")
.ok_or_else(|| ConversionError::Parse("docx: no document.xml".into()))?;
let styles = pkg.read("word/styles.xml").unwrap_or_default();
let numbering = pkg.read("word/numbering.xml").unwrap_or_default();
let rels: HashMap<String, String> = pkg
.rels_for("word/document.xml")
.iter()
.map(|r| {
let t = if r.rel_type.ends_with("/hyperlink") {
r.target.clone()
} else {
resolve("word", &r.target)
};
(r.id.clone(), t)
})
.collect();
let images = pkg.image_rels("word/document.xml", "word");
let charts = chart_rels(&mut pkg, "word/document.xml");
let (style_names, style_nums) = parse_styles(&styles);
let num_levels = parse_numbering(&numbering);
let dom =
Document::parse(&document).map_err(|e| ConversionError::with_source("docx", e))?;
let ctx = Ctx {
style_names: &style_names,
style_nums: &style_nums,
num_levels: &num_levels,
rels: &rels,
images: &images,
charts: &charts,
};
let mut doc = DoclingDocument::new(&source.name);
let Some(body) = dom.descendants().find(|n| n.has_tag_name("body")) else {
return Ok(doc);
};
let mut state = ListState::default();
for node in body.children().filter(XmlNode::is_element) {
process_block(node, &ctx, &mut state, &mut doc);
}
add_header_footer(&mut pkg, body, &ctx, &mut doc);
for comment in parse_comments(&mut pkg) {
doc.nodes.push(Node::Furniture {
layer: docling_core::ContentLayer::Notes,
inner: Box::new(Node::Paragraph { text: comment }),
});
}
Ok(doc)
}
}
fn add_header_footer(pkg: &mut Package, body: XmlNode, ctx: &Ctx, doc: &mut DoclingDocument) {
let doc_rels = ctx.rels;
let sect_prs: Vec<XmlNode> = body
.descendants()
.filter(|n| n.has_tag_name("sectPr"))
.collect();
let mut effective: HashMap<(&str, String), String> = HashMap::new();
for (sec_idx, sect) in sect_prs.iter().enumerate() {
for r in sect.children().filter(XmlNode::is_element) {
let kind = match r.tag_name().name() {
"headerReference" => "hdr",
"footerReference" => "ftr",
_ => continue,
};
let ty = attr(r, "type").unwrap_or("default").to_string();
if let Some(id) = attr(r, "id") {
effective.insert((kind, ty), id.to_string());
}
}
let title_pg = sect.children().any(|n| {
n.has_tag_name("titlePg")
&& attr(n, "val") != Some("false")
&& attr(n, "val") != Some("0")
});
if sec_idx > 0 && !title_pg {
continue;
}
let ty = if title_pg { "first" } else { "default" };
for kind in ["hdr", "ftr"] {
let Some(rid) = effective.get(&(kind, ty.to_string())) else {
continue;
};
let Some(part) = doc_rels.get(rid) else {
continue;
};
emit_header_footer_part(pkg, part, ctx, doc);
}
}
}
fn emit_header_footer_part(pkg: &mut Package, part: &str, ctx: &Ctx, doc: &mut DoclingDocument) {
let Some(xml) = pkg.read(part) else {
return;
};
let Ok(dom) = Document::parse(&xml) else {
return;
};
let root = dom.root_element();
let has_content = root
.descendants()
.any(|n| n.has_tag_name("t") && n.text().is_some_and(|t| !t.trim().is_empty()))
|| root.descendants().any(|n| {
matches!(
n.tag_name().name(),
"tbl" | "blip" | "imagedata" | "txbxContent"
)
});
if !has_content {
return;
}
let rels: HashMap<String, String> = pkg
.rels_for(part)
.iter()
.map(|r| {
let t = if r.rel_type.ends_with("/hyperlink") {
r.target.clone()
} else {
resolve("word", &r.target)
};
(r.id.clone(), t)
})
.collect();
let images = pkg.image_rels(part, "word");
let charts = chart_rels(pkg, part);
let part_ctx = Ctx {
style_names: ctx.style_names,
style_nums: ctx.style_nums,
num_levels: ctx.num_levels,
rels: &rels,
images: &images,
charts: &charts,
};
let mut sub = DoclingDocument::new("");
let mut state = ListState::default();
for node in root.children().filter(XmlNode::is_element) {
process_block(node, &part_ctx, &mut state, &mut sub);
}
for n in sub.nodes {
doc.nodes.push(Node::Furniture {
layer: docling_core::ContentLayer::Furniture,
inner: Box::new(n),
});
}
}
fn parse_comments(pkg: &mut Package) -> Vec<String> {
let Some(xml) = pkg.read("word/comments.xml") else {
return Vec::new();
};
let Ok(dom) = Document::parse(&xml) else {
return Vec::new();
};
let mut out = Vec::new();
for c in dom.descendants().filter(|n| n.has_tag_name("comment")) {
let author = attr(c, "author").unwrap_or("").trim();
let initials = attr(c, "initials").unwrap_or("").trim();
let date = attr(c, "date").map(format_comment_date).unwrap_or_default();
let text: String = c
.descendants()
.filter(|n| n.has_tag_name("t"))
.filter_map(|n| n.text())
.collect();
let head = if author.is_empty() {
format!("[time: {date}]")
} else if initials.is_empty() {
format!("[author: {author}, time: {date}]")
} else {
format!("[author: {author} ({initials}), time: {date}]")
};
out.push(format!("{head}: {text}"));
}
out
}
fn format_comment_date(raw: &str) -> String {
let base = raw.strip_suffix('Z').unwrap_or(raw);
let with_ms = if base.contains('.') {
base.to_string()
} else {
format!("{base}.000")
};
if raw.ends_with('Z') {
format!("{with_ms}+00:00")
} else {
with_ms
}
}
struct Ctx<'a> {
style_names: &'a HashMap<String, String>,
style_nums: &'a HashMap<String, (String, i64)>, num_levels: &'a HashMap<(String, i64), NumLevel>, rels: &'a HashMap<String, String>,
images: &'a HashMap<String, PictureImage>, charts: &'a HashMap<String, (String, Option<String>, docling_core::Table)>,
}
#[derive(Default)]
struct ListState {
counters: HashMap<(String, i64), i64>, numbered_headers: HashMap<u8, u64>, list_run_base: Option<i64>, seen_heading: bool,
}
fn process_block(node: XmlNode, ctx: &Ctx, state: &mut ListState, doc: &mut DoclingDocument) {
match node.tag_name().name() {
"p" => handle_paragraph(node, ctx, state, doc),
"tbl" => {
let rows: Vec<XmlNode> = node.children().filter(|n| n.has_tag_name("tr")).collect();
let num_cols = rows
.iter()
.map(|r| {
r.children()
.filter(|n| n.has_tag_name("tc"))
.map(grid_span)
.sum::<usize>()
})
.max()
.unwrap_or(0);
if rows.len() == 1 && num_cols == 1 {
if let Some(cell) = rows[0].children().find(|n| n.has_tag_name("tc")) {
for child in child_elements(cell) {
process_block(child, ctx, state, doc);
}
}
} else if let Some(table) = parse_table(node, ctx) {
doc.push(Node::Table(table));
state.list_run_base = None;
}
}
"sdt" => {
if let Some(content) = node.children().find(|n| n.has_tag_name("sdtContent")) {
for child in child_elements(content) {
process_block(child, ctx, state, doc);
}
}
}
_ => {}
}
}
fn handle_paragraph(p: XmlNode, ctx: &Ctx, state: &mut ListState, doc: &mut DoclingDocument) {
handle_paragraph_inner(p, ctx, state, doc, false, false)
}
fn handle_paragraph_inner(
p: XmlNode,
ctx: &Ctx,
state: &mut ListState,
doc: &mut DoclingDocument,
rich: bool,
skip_textbox: bool,
) {
let p_pr = p.children().find(|n| n.has_tag_name("pPr"));
let style_id = p_pr
.and_then(|pr| pr.children().find(|n| n.has_tag_name("pStyle")))
.and_then(|s| attr(s, "val"))
.unwrap_or("");
let style_name = ctx
.style_names
.get(style_id)
.cloned()
.unwrap_or_else(|| style_id.to_string());
if !skip_textbox {
let mut seen: Vec<(String, usize)> = Vec::new();
for tc in p.descendants().filter(|n| n.has_tag_name("txbxContent")) {
for (idx, tp) in tc.children().filter(|n| n.has_tag_name("p")).enumerate() {
let trimmed = paragraph_markdown(tp, ctx).trim().to_string();
let key = if trimmed.is_empty() {
(String::new(), idx)
} else {
(trimmed.clone(), usize::MAX)
};
if seen.contains(&key) {
continue;
}
seen.push(key);
if !trimmed.is_empty() {
handle_paragraph_inner(tp, ctx, state, doc, false, true);
} else {
doc.push(Node::Paragraph {
text: String::new(),
});
}
for image in drawing_images(tp, ctx, false) {
doc.push(Node::Picture {
caption: None,
image,
classification: None,
});
}
}
}
}
for image in drawing_images(p, ctx, true) {
doc.push(Node::Picture {
caption: None,
image,
classification: None,
});
}
for c in p.descendants().filter(|n| n.has_tag_name("chart")) {
if let Some((kind, title, table)) = attr(c, "id").and_then(|id| ctx.charts.get(id)) {
doc.push(Node::Chart {
kind: kind.clone(),
table: table.clone(),
caption: title.clone(),
location: None,
});
}
}
let eq_parts = collect_equation_parts(p);
let has_equations = eq_parts.iter().any(|part| matches!(part, EqPart::Eq(_)));
if has_equations && run_text(&eq_parts).trim().is_empty() {
for part in &eq_parts {
if let EqPart::Eq(eq) = part {
if !eq.is_empty() {
doc.push(Node::Paragraph {
text: format!("$${eq}$$"),
});
}
}
}
state.list_run_base = None;
return;
}
if p.descendants().any(|n| n.has_tag_name("checkbox")) {
let checked = p
.descendants()
.find(|n| n.has_tag_name("checked"))
.and_then(|n| attr(n, "val"))
== Some("1");
let text = clean_checkbox_symbols(¶graph_markdown(p, ctx));
doc.push(Node::CheckboxItem { checked, text });
state.list_run_base = None;
return;
}
let text = if has_equations {
serialize_inline_equations(&eq_parts)
} else {
paragraph_markdown(p, ctx)
};
let numbering = if p.descendants().any(|n| n.has_tag_name("numPr")) {
num_pr(p)
} else {
ctx.style_nums.get(style_id).cloned()
};
if let Some(level) = heading_level(&style_name) {
if !text.is_empty() {
let text = if numbering.is_some() {
let docling_level = level.saturating_sub(1).max(1);
numbered_heading_text(&mut state.numbered_headers, docling_level, &text)
} else {
text
};
doc.push(Node::Heading { level, text });
state.seen_heading = true;
}
state.list_run_base = None;
return;
}
if let Some((num_id, ilvl)) = numbering {
let numbered = ctx
.num_levels
.get(&(num_id.clone(), ilvl))
.map(|l| l.numbered)
.unwrap_or(false);
let base = *state.list_run_base.get_or_insert(ilvl);
let level = (ilvl - base).max(0) as u8;
if text.is_empty() {
return;
}
if numbered {
get_list_counter(&mut state.counters, ctx.num_levels, &num_id, ilvl);
let marker = build_enum_marker(&state.counters, ctx.num_levels, &num_id, ilvl);
let number = marker
.trim_end_matches(['.', ')'])
.rsplit(['.', ')'])
.next()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(1);
if cached_regex!(r"^\d+[.)]$").is_match(&marker) {
doc.push(Node::ListItem {
ordered: true,
number,
first_in_list: false,
text,
level,
marker: Some(marker),
location: None,
dclx: None,
href: None,
layer: None,
});
} else {
let dclx = Some(ListItemDclx {
ordered: true,
marker: Some(marker.clone()),
text: text.clone(),
runs: Vec::new(),
});
doc.push(Node::ListItem {
ordered: false,
number,
first_in_list: false,
text: format!("{marker} {text}"),
level,
marker: None,
location: None,
dclx,
href: None,
layer: None,
});
}
} else {
let dclx = if has_equations {
Some(ListItemDclx {
ordered: false,
marker: None,
text: text.clone(),
runs: inline_equation_runs(&eq_parts),
})
} else {
let mut tuples = Vec::new();
collect_run_tuples(p, Fmt::default(), None, ctx, &mut tuples);
let groups = run_groups(tuples);
groups
.iter()
.any(|(_, f, _)| f.underline || f.strike || f.script != 0)
.then(|| ListItemDclx {
ordered: false,
marker: None,
text: text.clone(),
runs: groups
.into_iter()
.filter(|(t, _, _)| !t.is_empty())
.map(|(t, f, _)| f.to_inline_run(&t))
.collect(),
})
};
doc.push(Node::ListItem {
ordered: false,
number: 0,
first_in_list: false,
text,
level,
marker: None,
location: None,
dclx,
href: None,
layer: None,
});
}
return;
}
state.list_run_base = None;
if !text.is_empty() {
if has_equations {
let runs = inline_equation_runs(&eq_parts);
doc.push(docling_core::inline_paragraph_node(text, runs, false));
} else if rich {
let mut tuples = Vec::new();
collect_run_tuples(p, Fmt::default(), None, ctx, &mut tuples);
for (t, f, l) in run_groups(tuples) {
let seg = serialize_run(&t, f, l.as_deref());
if seg.is_empty() {
continue;
}
if f == Fmt::default() {
doc.push(Node::Paragraph { text: seg });
} else {
doc.push(Node::InlineGroup {
unwrapped: false,
runs: vec![f.to_inline_run(&t)],
md_text: seg,
});
}
}
} else {
let mut tuples = Vec::new();
collect_run_tuples(p, Fmt::default(), None, ctx, &mut tuples);
let groups = run_groups(tuples);
let lone_link = groups.len() == 1 && groups[0].2.is_some();
if lone_link {
doc.push(Node::Paragraph { text });
} else {
let runs = groups
.into_iter()
.filter(|(t, _, _)| !t.is_empty())
.map(|(t, f, _)| f.to_inline_run(&t))
.collect();
doc.push(docling_core::inline_paragraph_node(
text,
runs,
state.seen_heading,
));
}
}
} else if !has_equations && !has_drawing(p) {
doc.push(Node::Paragraph {
text: String::new(),
});
}
}
fn has_drawing(p: XmlNode) -> bool {
p.descendants()
.any(|n| matches!(n.tag_name().name(), "drawing" | "pict" | "object"))
}
fn drawing_images(node: XmlNode, ctx: &Ctx, skip_textbox: bool) -> Vec<Option<PictureImage>> {
let keep = |n: XmlNode| !skip_textbox || !in_textbox(n);
let blips: Vec<XmlNode> = node
.descendants()
.filter(|n| n.has_tag_name("blip") && keep(*n))
.collect();
if !blips.is_empty() {
return blips
.iter()
.map(|b| attr(*b, "embed").and_then(|id| ctx.images.get(id)).cloned())
.collect();
}
let vml: Vec<Option<PictureImage>> = node
.descendants()
.filter(|n| n.has_tag_name("imagedata") && keep(*n))
.map(|d| attr(d, "id").and_then(|id| ctx.images.get(id)).cloned())
.collect();
if !vml.is_empty() {
return vml;
}
if node.descendants().any(|n| {
n.has_tag_name("drawing")
&& keep(n)
&& !n.descendants().any(|c| {
c.has_tag_name("chart")
&& attr(c, "id").is_some_and(|id| ctx.charts.contains_key(id))
})
}) {
return vec![None];
}
Vec::new()
}
fn chart_rels(
pkg: &mut Package,
part: &str,
) -> HashMap<String, (String, Option<String>, docling_core::Table)> {
let dir = part
.rsplit_once('/')
.map(|(d, _)| d)
.unwrap_or("")
.to_string();
let rels: Vec<(String, String)> = pkg
.rels_for(part)
.iter()
.filter(|r| r.rel_type.ends_with("/chart"))
.map(|r| (r.id.clone(), resolve(&dir, &r.target)))
.collect();
rels.into_iter()
.filter_map(|(id, path)| {
let spec = pkg
.read(&path)
.as_deref()
.and_then(crate::backend::xlsx_drawings::parse_chart)?;
let table = crate::backend::xlsx_drawings::chart_table_from_caches(&spec)?;
Some((id, (spec.kind.to_string(), spec.title, table)))
})
.collect()
}
fn in_textbox(n: XmlNode) -> bool {
n.ancestors()
.any(|a| a.has_tag_name("txbxContent") || a.has_tag_name("textbox"))
}
fn attr<'a>(node: XmlNode<'a, '_>, name: &str) -> Option<&'a str> {
node.attributes()
.find(|a| a.name() == name)
.map(|a| a.value())
}
fn numbered_heading_text(headers: &mut HashMap<u8, u64>, level: u8, text: &str) -> String {
*headers.entry(level).or_insert(0) += 1;
let mut out = format!("{} {}", headers[&level], text);
let mut next = level + 1;
while headers.contains_key(&next) {
headers.insert(next, 0);
next += 1;
}
let mut prev = level.wrapping_sub(1);
while prev >= 1 && headers.contains_key(&prev) {
let c = headers.get_mut(&prev).unwrap();
if *c == 0 {
*c = 1;
}
out = format!("{}.{}", *c, out);
prev = prev.wrapping_sub(1);
}
out
}
fn heading_level(style_name: &str) -> Option<u8> {
let lower = style_name.to_ascii_lowercase();
if lower == "title" {
return Some(1);
}
let rest = lower.strip_prefix("heading")?.trim();
rest.parse::<u8>().ok().map(|n| n.saturating_add(1))
}
fn num_pr(p: XmlNode) -> Option<(String, i64)> {
let num_pr = p.descendants().find(|n| n.has_tag_name("numPr"))?;
let num_id_node = num_pr.children().find(|n| n.has_tag_name("numId"))?;
let num_id = attr(num_id_node, "val")?.to_string();
if num_id == "0" {
return None;
}
let ilvl = num_pr
.children()
.find(|n| n.has_tag_name("ilvl"))
.and_then(|n| attr(n, "val"))
.and_then(|v| v.parse().ok())
.unwrap_or(0);
Some((num_id, ilvl))
}
fn paragraph_markdown(p: XmlNode, ctx: &Ctx) -> String {
let mut runs: Vec<(String, Fmt, Option<String>)> = Vec::new();
collect_run_tuples(p, Fmt::default(), None, ctx, &mut runs);
group_runs(runs)
}
fn child_elements<'a, 'i>(n: XmlNode<'a, 'i>) -> impl Iterator<Item = XmlNode<'a, 'i>> {
n.children().filter(XmlNode::is_element)
}
fn clean_checkbox_symbols(text: &str) -> String {
let t = text.trim();
for sym in ['☐', '☑', '☒', '□', '■', '▪', '▫'] {
if let Some(rest) = t.strip_prefix(sym) {
return rest.trim().to_string();
}
}
t.to_string()
}
fn run_groups(runs: Vec<(String, Fmt, Option<String>)>) -> Vec<(String, Fmt, Option<String>)> {
let mut groups: Vec<(String, Fmt, Option<String>)> = Vec::new();
let mut group_text = String::new();
let mut previous_format: Option<Fmt> = None;
let mut last_format = Fmt::default();
for (text, fmt, link) in runs {
last_format = fmt;
if (!text.trim().is_empty() && Some(fmt) != previous_format) || link.is_some() {
if !group_text.trim().is_empty() {
groups.push((
group_text.trim().to_string(),
previous_format.unwrap_or_default(),
None,
));
}
group_text.clear();
if link.is_some() {
groups.push((text.trim().to_string(), fmt, link));
continue;
}
previous_format = Some(fmt);
}
group_text.push_str(&text);
}
if !group_text.trim().is_empty() {
groups.push((group_text.trim().to_string(), last_format, None));
}
groups
}
fn run_segments(runs: Vec<(String, Fmt, Option<String>)>) -> Vec<String> {
run_groups(runs)
.iter()
.map(|(t, f, l)| serialize_run(t, *f, l.as_deref()))
.filter(|s| !s.is_empty())
.collect()
}
fn group_runs(runs: Vec<(String, Fmt, Option<String>)>) -> String {
run_segments(runs).join(" ")
}
enum EqPart {
Text(String),
Eq(String),
}
fn in_math(n: XmlNode) -> bool {
n.ancestors()
.any(|a| a.has_tag_name("oMath") || a.has_tag_name("oMathPara"))
}
fn collect_equation_parts(p: XmlNode) -> Vec<EqPart> {
let mut parts = Vec::new();
let has_direct = child_elements(p).any(|c| c.has_tag_name("oMath"));
if has_direct {
for child in child_elements(p) {
if child.has_tag_name("oMath") {
let eq = crate::backend::omml::to_latex(child);
if !eq.is_empty() {
parts.push(EqPart::Eq(eq));
}
} else {
for t in child
.descendants()
.filter(|n| n.has_tag_name("t") && !in_math(*n))
{
if let Some(txt) = t.text() {
parts.push(EqPart::Text(txt.to_string()));
}
}
}
}
} else {
for node in p.descendants() {
if node.has_tag_name("t") && !in_math(node) {
if let Some(txt) = node.text() {
parts.push(EqPart::Text(txt.to_string()));
}
} else if node.has_tag_name("oMath") {
let eq = crate::backend::omml::to_latex(node);
if !eq.is_empty() {
parts.push(EqPart::Eq(eq));
}
}
}
}
parts
}
fn run_text(parts: &[EqPart]) -> String {
parts
.iter()
.filter_map(|p| match p {
EqPart::Text(t) => Some(t.as_str()),
EqPart::Eq(_) => None,
})
.collect()
}
fn serialize_inline_equations(parts: &[EqPart]) -> String {
let mut merged: Vec<EqPart> = Vec::new();
for part in parts {
match part {
EqPart::Text(t) => {
if let Some(EqPart::Text(last)) = merged.last_mut() {
last.push_str(t);
} else {
merged.push(EqPart::Text(t.clone()));
}
}
EqPart::Eq(e) => merged.push(EqPart::Eq(e.clone())),
}
}
let n = merged.len();
let mut out: Vec<String> = Vec::new();
for (i, part) in merged.iter().enumerate() {
match part {
EqPart::Eq(e) => out.push(format!("${e}$")),
EqPart::Text(t) => {
let s = if i == n - 1 {
t.trim()
} else if i == 0 {
t.trim_start()
} else {
t.as_str()
};
if !s.is_empty() {
out.push(escape_text(s));
}
}
}
}
out.join(" ")
}
fn inline_equation_runs(parts: &[EqPart]) -> Vec<InlineRun> {
let mut merged: Vec<EqPart> = Vec::new();
for part in parts {
match part {
EqPart::Text(t) => {
if let Some(EqPart::Text(last)) = merged.last_mut() {
last.push_str(t);
} else {
merged.push(EqPart::Text(t.clone()));
}
}
EqPart::Eq(e) => merged.push(EqPart::Eq(e.clone())),
}
}
let n = merged.len();
let mut runs = Vec::new();
for (i, part) in merged.iter().enumerate() {
match part {
EqPart::Eq(e) => runs.push(InlineRun {
text: e.clone(),
formula: true,
..InlineRun::default()
}),
EqPart::Text(t) => {
let s = if i == n - 1 {
t.trim()
} else if i == 0 {
t.trim_start()
} else {
t.as_str()
};
if !s.is_empty() {
runs.push(InlineRun {
text: s.to_string(),
..InlineRun::default()
});
}
}
}
}
runs
}
#[derive(Clone, Copy, Default, PartialEq)]
struct Fmt {
bold: bool,
italic: bool,
strike: bool,
underline: bool,
script: u8,
}
impl Fmt {
fn to_inline_run(self, text: &str) -> InlineRun {
InlineRun {
text: text.to_string(),
bold: self.bold,
italic: self.italic,
underline: self.underline,
strike: self.strike,
script: match self.script {
1 => Script::Sub,
2 => Script::Super,
_ => Script::Baseline,
},
code: false,
formula: false,
}
}
}
fn collect_run_tuples(
node: XmlNode,
fmt: Fmt,
link: Option<&str>,
ctx: &Ctx,
out: &mut Vec<(String, Fmt, Option<String>)>,
) {
for child in node.children().filter(XmlNode::is_element) {
collect_one(child, fmt, link, ctx, out);
}
}
fn collect_one(
child: XmlNode,
fmt: Fmt,
link: Option<&str>,
ctx: &Ctx,
out: &mut Vec<(String, Fmt, Option<String>)>,
) {
match child.tag_name().name() {
"r" => {
let run_fmt = run_format(child, fmt);
let text: String = child_elements(child)
.map(|n| match n.tag_name().name() {
"t" => n.text().unwrap_or("").to_string(),
"br" | "cr" => "\n".to_string(),
"tab" => "\t".to_string(),
_ => String::new(),
})
.collect();
if !text.is_empty() {
out.push((text, run_fmt, link.map(str::to_string)));
}
}
"hyperlink" => {
let url = attr(child, "id").and_then(|id| ctx.rels.get(id)).cloned();
let mut inner = Vec::new();
collect_run_tuples(child, fmt, None, ctx, &mut inner);
let text: String = inner.iter().map(|(t, _, _)| t.as_str()).collect();
let lfmt = inner.first().map(|(_, f, _)| *f).unwrap_or(fmt);
if !text.trim().is_empty() {
out.push((text, lfmt, url.or(link.map(str::to_string))));
}
}
"smartTag" | "ins" | "fldSimple" | "sdt" | "sdtContent" => {
collect_run_tuples(child, fmt, link, ctx, out)
}
_ => {}
}
}
fn run_format(r: XmlNode, base: Fmt) -> Fmt {
let Some(r_pr) = r.children().find(|n| n.has_tag_name("rPr")) else {
return base;
};
let on = |name: &str| -> bool {
r_pr.children()
.find(|n| n.has_tag_name(name))
.map(|n| attr(n, "val") != Some("false") && attr(n, "val") != Some("0"))
.unwrap_or(false)
};
let script = match r_pr
.children()
.find(|n| n.has_tag_name("vertAlign"))
.and_then(|n| attr(n, "val"))
{
Some("subscript") => 1,
Some("superscript") => 2,
_ => base.script,
};
Fmt {
bold: base.bold || on("b"),
italic: base.italic || on("i"),
strike: base.strike || on("strike"),
underline: base.underline || on("u"),
script,
}
}
fn serialize_run(text: &str, fmt: Fmt, link: Option<&str>) -> String {
let mut s = escape_text(text);
if fmt.bold {
s = format!("**{s}**");
}
if fmt.italic {
s = format!("*{s}*");
}
if fmt.strike {
s = format!("~~{s}~~");
}
if let Some(url) = link {
s = format!("[{s}]({url})");
}
s
}
fn parse_table(tbl: XmlNode, ctx: &Ctx) -> Option<Table> {
parse_table_with(tbl, ctx, false)
}
fn parse_table_with(tbl: XmlNode, ctx: &Ctx, nested: bool) -> Option<Table> {
let rows: Vec<XmlNode> = tbl.children().filter(|n| n.has_tag_name("tr")).collect();
let num_cols = rows
.iter()
.map(|r| {
let (before, after) = row_grid_offsets(*r);
before
+ after
+ r.children()
.filter(|n| n.has_tag_name("tc"))
.map(|tc| grid_span(tc))
.sum::<usize>()
})
.max()
.unwrap_or(0);
if rows.is_empty() || num_cols == 0 {
return None;
}
let mut grid: Vec<Vec<String>> = vec![vec![String::new(); num_cols]; rows.len()];
let mut blocks: Vec<Vec<Vec<Node>>> = vec![vec![Vec::new(); num_cols]; rows.len()];
let mut any_rich = false;
let mut col_cont = vec![vec![false; num_cols]; rows.len()];
let mut row_cont = vec![vec![false; num_cols]; rows.len()];
let mut any_span = false;
for (ri, row) in rows.iter().enumerate() {
let mut ci = row_grid_offsets(*row).0;
for tc in row.children().filter(|n| n.has_tag_name("tc")) {
let span = grid_span(tc);
let v_continue = tc
.descendants()
.find(|n| n.has_tag_name("vMerge"))
.map(|n| attr(n, "val").unwrap_or("continue") != "restart")
.unwrap_or(false);
let text = if v_continue && ri > 0 {
grid[ri - 1][ci].clone()
} else if nested {
tc.children()
.filter(|n| n.has_tag_name("p"))
.map(plain_paragraph_text)
.collect::<Vec<_>>()
.join("\n")
} else {
cell_markdown(tc, ctx)
};
if ci < num_cols {
let cb = if v_continue && ri > 0 {
blocks[ri - 1][ci].clone()
} else if nested {
Vec::new()
} else {
cell_blocks_of(tc, ctx)
};
if !cb.is_empty() {
any_rich = true;
blocks[ri][ci] = cb;
}
}
let col_end = (ci + span).min(num_cols);
for cell in grid[ri].iter_mut().take(col_end).skip(ci) {
*cell = text.clone();
}
for c in col_cont[ri].iter_mut().take(col_end).skip(ci + 1) {
*c = true;
any_span = true;
}
if v_continue && ri > 0 {
for c in row_cont[ri].iter_mut().take(col_end).skip(ci) {
*c = true;
}
any_span = true;
}
ci += span;
}
}
let structure = any_span.then(|| {
let mut header_row = vec![false; rows.len()];
if let Some(h) = header_row.first_mut() {
*h = true;
}
docling_core::TableStructure {
header_row,
col_continuation: col_cont,
row_continuation: row_cont,
row_header: Vec::new(),
col_header: Vec::new(),
}
});
Some(Table {
rows: grid,
location: None,
structure,
cell_blocks: any_rich.then_some(blocks),
})
}
fn grid_span(tc: XmlNode) -> usize {
tc.descendants()
.find(|n| n.has_tag_name("gridSpan"))
.and_then(|n| attr(n, "val"))
.and_then(|v| v.parse().ok())
.unwrap_or(1)
}
fn row_grid_offsets(tr: XmlNode) -> (usize, usize) {
let read = |tag: &str| {
tr.children()
.find(|n| n.has_tag_name("trPr"))
.and_then(|pr| pr.children().find(|n| n.has_tag_name(tag)))
.and_then(|n| attr(n, "val"))
.and_then(|v| v.parse().ok())
.unwrap_or(0)
};
(read("gridBefore"), read("gridAfter"))
}
fn cell_markdown(tc: XmlNode, ctx: &Ctx) -> String {
if is_rich_cell(tc) {
rich_cell_markdown(tc, ctx)
} else {
tc.children()
.filter(|n| n.has_tag_name("p"))
.map(plain_paragraph_text)
.collect::<Vec<_>>()
.join("\n")
}
}
fn is_rich_cell(tc: XmlNode) -> bool {
let paras: Vec<XmlNode> = child_elements(tc).filter(|c| c.has_tag_name("p")).collect();
if paras.len() > 1 {
return true;
}
if child_elements(tc).any(|c| !matches!(c.tag_name().name(), "p" | "tcPr")) {
return true;
}
if tc.descendants().any(|n| n.has_tag_name("blip")) {
return true;
}
paras
.iter()
.flat_map(|p| child_elements(*p).filter(|c| c.has_tag_name("r")))
.any(run_has_format)
}
fn run_has_format(r: XmlNode) -> bool {
let Some(rpr) = child_elements(r).find(|c| c.has_tag_name("rPr")) else {
return false;
};
child_elements(rpr).any(|c| {
matches!(
c.tag_name().name(),
"b" | "i" | "strike" | "u" | "vertAlign"
) && attr(c, "val") != Some("false")
&& attr(c, "val") != Some("0")
&& attr(c, "val") != Some("none")
})
}
fn rich_cell_markdown(tc: XmlNode, ctx: &Ctx) -> String {
let mut sub = DoclingDocument::new("");
let mut state = ListState::default();
for child in child_elements(tc) {
match child.tag_name().name() {
"p" => handle_paragraph_inner(child, ctx, &mut state, &mut sub, true, false),
"tbl" => {
if let Some(table) = parse_table_with(child, ctx, true) {
let text = table
.rows
.iter()
.flatten()
.filter(|c| !c.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ");
if !text.is_empty() {
sub.push(Node::Paragraph { text });
}
}
}
_ => {}
}
}
sub.export_to_markdown().trim().to_string()
}
fn cell_blocks_of(tc: XmlNode, ctx: &Ctx) -> Vec<Node> {
if !is_rich_cell(tc) {
return Vec::new();
}
let mut sub = DoclingDocument::new("");
let mut state = ListState::default();
for child in child_elements(tc) {
match child.tag_name().name() {
"p" => handle_paragraph_inner(child, ctx, &mut state, &mut sub, true, false),
"tbl" => {
if let Some(table) = parse_table_with(child, ctx, false) {
sub.push(Node::Table(table));
}
}
_ => {}
}
}
sub.nodes
}
fn omaths_of<'a, 'i>(child: XmlNode<'a, 'i>) -> Vec<XmlNode<'a, 'i>> {
if child.has_tag_name("oMath") {
vec![child]
} else if child.has_tag_name("oMathPara") {
child
.descendants()
.filter(|d| d.has_tag_name("oMath"))
.collect()
} else {
vec![]
}
}
fn plain_paragraph_text(p: XmlNode) -> String {
let mut out = String::new();
for child in child_elements(p) {
let omaths = omaths_of(child);
if omaths.is_empty() {
for t in child.descendants().filter(|n| n.has_tag_name("t")) {
out.push_str(t.text().unwrap_or(""));
}
} else {
for m in omaths {
let eq = crate::backend::omml::to_latex(m);
if !eq.is_empty() {
out.push('$');
out.push_str(&eq);
out.push('$');
}
}
}
}
out
}
type StyleMaps = (HashMap<String, String>, HashMap<String, (String, i64)>);
fn parse_styles(styles_xml: &str) -> StyleMaps {
let mut names = HashMap::new();
let mut nums = HashMap::new();
let Ok(dom) = Document::parse(styles_xml) else {
return (names, nums);
};
for style in dom.descendants().filter(|n| n.has_tag_name("style")) {
let Some(id) = attr(style, "styleId") else {
continue;
};
if let Some(name) = style
.children()
.find(|n| n.has_tag_name("name"))
.and_then(|n| attr(n, "val"))
{
names.insert(id.to_string(), name.to_string());
}
if let Some(num) = num_pr(style) {
nums.insert(id.to_string(), num);
}
}
(names, nums)
}
#[derive(Clone, Default)]
struct NumLevel {
numbered: bool,
start: i64,
lvl_text: String,
}
fn parse_numbering(numbering_xml: &str) -> HashMap<(String, i64), NumLevel> {
let mut out = HashMap::new();
let Ok(dom) = Document::parse(numbering_xml) else {
return out;
};
let mut num_to_abstract: HashMap<String, String> = HashMap::new();
for num in dom.descendants().filter(|n| n.has_tag_name("num")) {
if let (Some(id), Some(abs)) = (
attr(num, "numId"),
num.descendants()
.find(|n| n.has_tag_name("abstractNumId"))
.and_then(|n| attr(n, "val")),
) {
num_to_abstract.insert(id.to_string(), abs.to_string());
}
}
let mut abstract_levels: HashMap<String, HashMap<i64, NumLevel>> = HashMap::new();
for abs in dom.descendants().filter(|n| n.has_tag_name("abstractNum")) {
let Some(abs_id) = attr(abs, "abstractNumId") else {
continue;
};
let mut levels = HashMap::new();
for lvl in abs.children().filter(|n| n.has_tag_name("lvl")) {
let ilvl: i64 = attr(lvl, "ilvl").and_then(|v| v.parse().ok()).unwrap_or(0);
let numbered = lvl
.children()
.find(|n| n.has_tag_name("numFmt"))
.and_then(|n| attr(n, "val"))
.map(|v| v != "bullet")
.unwrap_or(true);
let start = lvl
.children()
.find(|n| n.has_tag_name("start"))
.and_then(|n| attr(n, "val"))
.and_then(|v| v.parse().ok())
.unwrap_or(1);
let lvl_text = lvl
.children()
.find(|n| n.has_tag_name("lvlText"))
.and_then(|n| attr(n, "val"))
.unwrap_or("")
.to_string();
levels.insert(
ilvl,
NumLevel {
numbered,
start,
lvl_text,
},
);
}
abstract_levels.insert(abs_id.to_string(), levels);
}
for (num_id, abs_id) in num_to_abstract {
if let Some(levels) = abstract_levels.get(&abs_id) {
for (ilvl, lvl) in levels {
out.insert((num_id.clone(), *ilvl), lvl.clone());
}
}
}
out
}
fn level_start(num_levels: &HashMap<(String, i64), NumLevel>, num_id: &str, ilvl: i64) -> i64 {
num_levels
.get(&(num_id.to_string(), ilvl))
.map(|l| l.start)
.unwrap_or(1)
}
fn get_list_counter(
counters: &mut HashMap<(String, i64), i64>,
num_levels: &HashMap<(String, i64), NumLevel>,
num_id: &str,
ilvl: i64,
) {
let key = (num_id.to_string(), ilvl);
let c = counters
.entry(key)
.or_insert(level_start(num_levels, num_id, ilvl) - 1);
*c += 1;
for (k, v) in counters.iter_mut() {
if k.0 == num_id && k.1 > ilvl {
*v = 0;
}
}
}
fn build_enum_marker(
counters: &HashMap<(String, i64), i64>,
num_levels: &HashMap<(String, i64), NumLevel>,
num_id: &str,
ilvl: i64,
) -> String {
let counter_at = |lvl: i64| -> i64 {
counters
.get(&(num_id.to_string(), lvl))
.copied()
.unwrap_or_else(|| level_start(num_levels, num_id, lvl))
};
let lvl_text = num_levels
.get(&(num_id.to_string(), ilvl))
.map(|l| l.lvl_text.as_str())
.unwrap_or("");
let re_placeholder = cached_regex!(r"%(\d+)");
if re_placeholder.is_match(lvl_text) {
let stripped: String = re_placeholder.replace_all(lvl_text, "").into_owned();
let stripped = stripped.trim_matches(|c: char| " .)(:[]".contains(c));
if !stripped.is_empty() {
return re_placeholder
.replace_all(lvl_text, |caps: ®ex::Captures| {
let lvl_idx: i64 = caps[1].parse::<i64>().unwrap_or(1) - 1;
counter_at(lvl_idx).to_string()
})
.into_owned();
}
}
let parts: Vec<String> = (0..=ilvl).map(|lvl| counter_at(lvl).to_string()).collect();
parts.join(".") + "."
}