use std::collections::{HashMap, HashSet, VecDeque};
use docling_core::tree::{Formatting, ItemTree, ListMeta, TreeKind, TreeNote, TreeProv};
use docling_core::{ContentLayer, PictureImage, Script, Table, TableCell};
use roxmltree::Node as XmlNode;
use super::odf::{
attr, cell_has_image, collect_runs, element_has_text, image_can_be_bitmap, inline_chart,
is_list_tag, is_rich_cell, is_slide_title_element, level_affixes, level_is_enumerated,
level_start, list_has_direct_text, list_has_renderable, list_items,
list_starts_with_empty_nested, odf_item_content, ods_cell_text, para_plain_text,
paragraph_style_names, repeat, slide_has_visible_title, Fmt, Run, Styles,
};
pub(super) struct Built {
pub(super) tree: ItemTree,
pub(super) pages: Vec<(usize, f64, f64)>,
}
#[derive(Clone, Copy)]
struct ListState {
group: usize,
last_item: Option<usize>,
enumerated: bool,
counter: i64,
}
struct Walker<'s> {
tree: ItemTree,
styles: &'s Styles,
tables: usize,
note_calls: Vec<(usize, usize, roxmltree::NodeId)>,
}
pub(super) fn build_text(body: XmlNode, styles: &Styles) -> Built {
let mut w = Walker::new(styles);
w.add_children(body.children().filter(XmlNode::is_element), None, None);
let mut bodies = std::collections::HashMap::new();
for (note, text) in super::odf::note_bodies(body) {
let id = w.tree.add(
None,
Some(ContentLayer::Furniture),
TreeKind::Text {
label: "footnote".into(),
text: text.clone(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: None,
},
);
bodies.insert(note, (id, text));
}
for (item, offset, note) in std::mem::take(&mut w.note_calls) {
if let Some((body_id, text)) = bodies.get(¬e) {
w.tree.items[item].notes.push(TreeNote {
offset,
text: text.clone(),
});
w.tree.items[*body_id].note_body = true;
}
}
Built {
tree: w.tree,
pages: Vec::new(),
}
}
pub(super) fn build_presentation(pres: XmlNode, styles: &Styles) -> Built {
let mut w = Walker::new(styles);
for (idx, page) in pres
.children()
.filter(|c| c.has_tag_name("page"))
.enumerate()
{
let slide_name = attr(page, "name")
.filter(|n| !n.is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("slide-{}", idx + 1));
let slide = w.tree.add(
None,
None,
TreeKind::Group {
label: "chapter".into(),
name: format!("slide-{idx}"),
},
);
if !slide_has_visible_title(page) {
w.tree.add(
Some(slide),
None,
text_kind("title", &slide_name, None, None),
);
}
w.walk_slide(page, slide);
}
Built {
tree: w.tree,
pages: Vec::new(),
}
}
pub(super) fn build_spreadsheet(sheet: XmlNode, styles: &Styles) -> Built {
let mut w = Walker::new(styles);
let mut pages = Vec::new();
for (ix, table) in sheet
.children()
.filter(|c| c.has_tag_name("table"))
.enumerate()
{
let page_no = ix + 1;
let layer = (attr(table, "display") == Some("false")).then_some(ContentLayer::Invisible);
let group = w.tree.add(
None,
layer,
TreeKind::Group {
label: "section".into(),
name: format!("sheet: {}", attr(table, "name").unwrap_or("")),
},
);
let first = w.tree.items.len();
w.convert_sheet(table, group, page_no, layer);
let mut ext: Option<(f64, f64, f64, f64)> = None;
for item in &w.tree.items[first..] {
if let Some(p) = &item.prov {
let [l, t, r, b] = p.bbox;
ext = Some(match ext {
None => (l, t, r, b),
Some((el, et, er, eb)) => (el.min(l), et.min(t), er.max(r), eb.max(b)),
});
}
}
let (w_, h_) = ext.map_or((0.0, 0.0), |(l, t, r, b)| (r - l, b - t));
pages.push((page_no, w_, h_));
}
Built {
tree: w.tree,
pages,
}
}
fn text_kind(
label: &str,
text: &str,
formatting: Option<Formatting>,
hyperlink: Option<&str>,
) -> TreeKind {
TreeKind::Text {
label: label.into(),
text: text.into(),
orig: None,
formatting,
hyperlink: hyperlink.map(str::to_string),
level: None,
list: None,
}
}
fn formatting_of(fmt: Fmt) -> Option<Formatting> {
(fmt != Fmt::default()).then_some(Formatting {
bold: fmt.bold,
italic: fmt.italic,
underline: fmt.underline,
strikethrough: fmt.strike,
script: match fmt.script {
1 => Script::Sub,
2 => Script::Super,
_ => Script::Baseline,
},
})
}
fn normalize_runs(runs: Vec<Run>) -> Vec<Run> {
let mut merged: Vec<Run> = Vec::new();
for run in runs {
if run.text.is_empty() {
continue;
}
if let Some(last) = merged.last_mut() {
if last.fmt == run.fmt && last.href == run.href {
last.text.push_str(&run.text);
continue;
}
}
let mut text = run.text;
if let Some(last) = merged.last_mut() {
if last.href != run.href {
last.text = last.text.trim_end().to_string();
text = text.trim_start().to_string();
}
}
merged.push(Run {
text,
fmt: run.fmt,
href: run.href,
});
}
while merged.first().is_some_and(|r| r.text.trim().is_empty()) {
merged.remove(0);
}
if let Some(first) = merged.first_mut() {
first.text = first.text.trim_start().to_string();
}
while merged.last().is_some_and(|r| r.text.trim().is_empty()) {
merged.pop();
}
if let Some(last) = merged.last_mut() {
last.text = last.text.trim_end().to_string();
}
merged.retain(|r| !r.text.is_empty());
merged
}
fn text_from_runs(runs: &[Run]) -> String {
runs.iter()
.map(|r| r.text.as_str())
.collect::<String>()
.trim()
.to_string()
}
fn runs_of(el: XmlNode, styles: &Styles) -> Vec<Run> {
let mut runs = Vec::new();
collect_runs(el, styles, Fmt::default(), &mut runs);
normalize_runs(runs)
}
fn clean_lines_vec(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
fn image_href<'a>(img: XmlNode<'a, '_>) -> Option<&'a str> {
attr(img, "href")
}
fn strip_image_refs(text: &str, images: &[XmlNode]) -> String {
let mut remaining = text.to_string();
for img in images {
if let Some(href) = image_href(*img) {
let href = href.trim();
for r in [href, href.strip_prefix("./").unwrap_or(href)] {
remaining = remaining.replace(&format!("({r})"), "");
}
}
}
remaining
}
struct Grid<'a, 'i> {
anchors: HashMap<(usize, usize), XmlNode<'a, 'i>>,
covered: HashSet<(usize, usize)>,
}
fn grid_of<'a, 'i>(
table: XmlNode<'a, 'i>,
within: Option<(usize, usize, usize, usize)>,
) -> Grid<'a, 'i> {
let mut anchors = HashMap::new();
let mut covered = HashSet::new();
let rows = table.descendants().filter(|n| {
n.has_tag_name("table-row")
&& n.ancestors().find(|a| a.has_tag_name("table")) == Some(table)
});
let inside = |r: usize, c: usize| {
within.is_none_or(|(r0, r1, c0, c1)| (r0..=r1).contains(&r) && (c0..=c1).contains(&c))
};
let mut r = 0usize;
for row in rows {
let rrep = repeat(row, "number-rows-repeated");
if let Some((_, r1, _, _)) = within {
if r > r1 {
break;
}
}
let mut c = 0usize;
let mut row_cells: Vec<(usize, XmlNode, bool)> = Vec::new();
for cell in row
.children()
.filter(|n| n.has_tag_name("table-cell") || n.has_tag_name("covered-table-cell"))
{
let crep = repeat(cell, "number-columns-repeated");
let is_covered = cell.has_tag_name("covered-table-cell");
if is_covered || within.is_some() || cell_has_content(cell) {
for k in 0..crep.min(1024) {
row_cells.push((c + k, cell, is_covered));
}
}
c += crep;
}
if !row_cells.is_empty() {
for k in 0..rrep.min(1024) {
for (cc, cell, is_covered) in &row_cells {
if !inside(r + k, *cc) {
continue;
}
if *is_covered {
covered.insert((r + k, *cc));
} else {
anchors.insert((r + k, *cc), *cell);
}
}
}
}
r += rrep;
}
Grid { anchors, covered }
}
fn span(cell: XmlNode, name: &str) -> usize {
attr(cell, name)
.and_then(|v| v.parse::<usize>().ok())
.filter(|&n| n >= 1)
.unwrap_or(1)
}
fn cell_has_content(cell: XmlNode) -> bool {
!cell_text(cell).is_empty() || cell_has_image(cell)
}
fn cell_text(cell: XmlNode) -> String {
let lines: Vec<String> = cell
.children()
.filter(XmlNode::is_element)
.flat_map(element_text_lines)
.collect();
let child_text = lines.join("\n");
if rich_content(cell) {
let images: Vec<XmlNode> = cell
.descendants()
.filter(|n| n.has_tag_name("image"))
.collect();
return strip_image_refs(&child_text, &images);
}
if let Some(value_type) = attr(cell, "value-type") {
let value = match value_type {
"string" => Some(
cell.children()
.filter(|c| c.has_tag_name("p") || c.has_tag_name("h"))
.map(para_plain_text)
.collect::<Vec<_>>()
.join("\n"),
),
"float" | "percentage" | "currency" => attr(cell, "value").map(str::to_string),
"date" => attr(cell, "date-value").map(str::to_string),
"time" => attr(cell, "time-value").map(str::to_string),
"boolean" => attr(cell, "boolean-value").map(|b| {
if b == "true" {
"True".to_string()
} else {
"False".to_string()
}
}),
_ => None,
};
if let Some(v) = value {
return v;
}
}
if !child_text.is_empty() {
return child_text;
}
if cell.children().any(|c| c.is_element()) {
return String::new();
}
clean_lines_vec(¶_plain_text(cell)).join("\n")
}
fn rich_content(cell: XmlNode) -> bool {
if cell_has_image(cell) {
return true;
}
let mut paragraphs = 0;
for child in cell.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
n if is_list_tag(n) => {
if !element_text_lines(child).is_empty() {
return true;
}
}
"h" if !clean_lines_vec(¶_plain_text(child)).is_empty() => return true,
"table" if super::odf::table_has_content(child) => return true,
"p" if !clean_lines_vec(¶_plain_text(child)).is_empty() => paragraphs += 1,
_ => {}
}
}
paragraphs > 1 || (attr(cell, "value-type").is_none() && paragraphs > 0)
}
fn element_text_lines(el: XmlNode) -> Vec<String> {
match el.tag_name().name() {
n if is_list_tag(n) => list_items(el).flat_map(element_text_lines).collect(),
"list-item" | "list-header" => {
let lines: Vec<String> = el
.children()
.filter(XmlNode::is_element)
.flat_map(element_text_lines)
.collect();
if lines.is_empty() {
clean_lines_vec(¶_plain_text(el))
} else {
lines
}
}
"p" | "h" => clean_lines_vec(¶_plain_text(el)),
_ => {
let lines: Vec<String> = el
.children()
.filter(XmlNode::is_element)
.flat_map(element_text_lines)
.collect();
if lines.is_empty() {
clean_lines_vec(¶_plain_text(el))
} else {
lines
}
}
}
}
fn data_bounds(grid: &Grid) -> (usize, usize, usize, usize) {
let mut bounds: Option<(usize, usize, usize, usize)> = None;
let mut extend = |r0: usize, r1: usize, c0: usize, c1: usize| {
bounds = Some(match bounds {
None => (r0, r1, c0, c1),
Some((a, b, c, d)) => (a.min(r0), b.max(r1), c.min(c0), d.max(c1)),
});
};
for (&(r, c), cell) in &grid.anchors {
extend(r, r, c, c);
let (rs, cs) = (
span(*cell, "number-rows-spanned"),
span(*cell, "number-columns-spanned"),
);
if rs > 1 || cs > 1 {
extend(r, r + rs - 1, c, c + cs - 1);
}
}
for &(r, c) in &grid.covered {
extend(r, r, c, c);
}
bounds.unwrap_or((0, 0, 0, 0))
}
impl<'s> Walker<'s> {
fn new(styles: &'s Styles) -> Self {
Walker {
tree: ItemTree::default(),
styles,
tables: 0,
note_calls: Vec::new(),
}
}
fn add_text_runs(
&mut self,
runs: &[Run],
label: &str,
parent: Option<usize>,
layer: Option<ContentLayer>,
) -> Option<usize> {
match runs {
[] => None,
[run] => Some(self.tree.add(
parent,
layer,
text_kind(
label,
&run.text,
formatting_of(run.fmt),
run.href.as_deref(),
),
)),
_ => {
let group = self.inline_group(parent, layer);
for run in runs {
self.tree.add(
Some(group),
layer,
text_kind(
label,
&run.text,
formatting_of(run.fmt),
run.href.as_deref(),
),
);
}
Some(group)
}
}
}
fn inline_group(&mut self, parent: Option<usize>, layer: Option<ContentLayer>) -> usize {
self.tree.add(
parent,
layer,
TreeKind::Group {
label: "inline".into(),
name: "group".into(),
},
)
}
fn add_rich_block(
&mut self,
runs: &[Run],
parent: Option<usize>,
layer: Option<ContentLayer>,
block: impl Fn(&str, Option<Formatting>, Option<&str>) -> TreeKind,
) {
match runs {
[] => {}
[run] => {
self.tree.add(
parent,
layer,
block(&run.text, formatting_of(run.fmt), run.href.as_deref()),
);
}
_ => {
let item = self.tree.add(parent, layer, block("", None, None));
let group = self.inline_group(Some(item), layer);
for run in runs {
self.tree.add(
Some(group),
layer,
text_kind(
"text",
&run.text,
formatting_of(run.fmt),
run.href.as_deref(),
),
);
}
}
}
}
fn add_heading(&mut self, el: XmlNode, parent: Option<usize>, layer: Option<ContentLayer>) {
let level = attr(el, "outline-level")
.or_else(|| attr(el, "level"))
.and_then(|v| v.parse::<i64>().ok())
.unwrap_or(1)
.max(1) as u8;
let runs = runs_of(el, self.styles);
self.add_rich_block(&runs, parent, layer, |text, f, h| TreeKind::Text {
label: "section_header".into(),
text: text.into(),
orig: None,
formatting: f,
hyperlink: h.map(str::to_string),
level: Some(level),
list: None,
});
}
fn add_charts(
&mut self,
el: XmlNode,
parent: Option<usize>,
layer: Option<ContentLayer>,
) -> usize {
let mut frames: Vec<XmlNode> = Vec::new();
if el.has_tag_name("frame") {
frames.push(el);
}
frames.extend(
el.descendants()
.filter(|n| n.has_tag_name("frame") && *n != el),
);
let mut count = 0;
for frame in frames {
let Some(obj) = frame.children().find(|c| c.has_tag_name("object")) else {
continue;
};
let name = attr(obj, "href").unwrap_or("").trim_start_matches("./");
let info = self
.styles
.charts
.get(name)
.cloned()
.or_else(|| inline_chart(obj, self.styles));
let Some(info) = info else {
continue;
};
self.tree.add(
parent,
layer,
TreeKind::Picture {
captions: Vec::new(),
image: None,
classification: Some(info.kind),
confidence: None,
chart: Some(info.table),
dpi: None,
},
);
count += 1;
}
count
}
fn add_images(
&mut self,
images: &[XmlNode],
parent: Option<usize>,
layer: Option<ContentLayer>,
skip_object_replacements: bool,
) -> usize {
let mut count = 0;
for img in images {
let href = image_href(*img).unwrap_or("");
if skip_object_replacements
&& href
.trim_start_matches("./")
.starts_with("ObjectReplacements/")
{
continue;
}
let Some(image) = self.image_payload(*img, href) else {
continue;
};
self.tree.add(
parent,
layer,
TreeKind::Picture {
captions: Vec::new(),
image: Some(image),
classification: None,
confidence: None,
chart: None,
dpi: None,
},
);
count += 1;
}
count
}
fn image_payload(&self, img: XmlNode, href: &str) -> Option<PictureImage> {
if !image_can_be_bitmap(img, href) {
return None;
}
let name = href.trim_start_matches("./").trim_start_matches('#');
self.styles.images.get(name).cloned()
}
fn add_paragraph(&mut self, el: XmlNode, parent: Option<usize>, layer: Option<ContentLayer>) {
let chart_count = self.add_charts(el, parent, layer);
let images: Vec<XmlNode> = el
.descendants()
.filter(|n| n.has_tag_name("image"))
.collect();
let image_count = self.add_images(&images, parent, layer, chart_count > 0);
let mut runs = runs_of(el, self.styles);
let mut text = text_from_runs(&runs);
if !images.is_empty() {
let stripped = strip_image_refs(&text, &images).trim().to_string();
if stripped != text {
runs = if stripped.is_empty() {
Vec::new()
} else {
vec![Run {
text: stripped.clone(),
fmt: Fmt::default(),
href: None,
}]
};
text = stripped;
}
}
if image_count > 0 && strip_image_refs(&text, &images).trim().is_empty() {
return;
}
if chart_count > 0 && (text.contains("ObjectReplacements") || text.is_empty()) {
return;
}
let names = paragraph_style_names(self.styles, attr(el, "style-name"));
if names.iter().any(|n| n == "Title") {
self.add_rich_block(&runs, parent, layer, |t, f, h| text_kind("title", t, f, h));
} else if names.iter().any(|n| n == "Subtitle") {
self.add_rich_block(&runs, parent, layer, |t, f, h| TreeKind::Text {
label: "section_header".into(),
text: t.into(),
orig: None,
formatting: f,
hyperlink: h.map(str::to_string),
level: Some(1),
list: None,
});
} else {
self.add_text_runs(&runs, "text", parent, layer);
}
}
fn add_children<'a, 'i: 'a>(
&mut self,
els: impl Iterator<Item = XmlNode<'a, 'i>>,
parent: Option<usize>,
layer: Option<ContentLayer>,
) {
let mut prev: Option<ListState> = None;
for el in els {
if is_list_tag(el.tag_name().name()) {
prev = self.add_list(el, parent, layer, false, 1, prev);
} else {
prev = None;
self.add_child(el, parent, layer);
}
}
}
fn anchor_notes(&mut self, el: XmlNode, first_new: usize) {
if !el.descendants().any(|n| n.has_tag_name("note")) {
return;
}
let (text, calls) = super::odf::note_calls(el);
let offsets: Vec<usize> = calls.iter().map(|c| c.0).collect();
let placed = self.tree.place_note_calls(first_new, &text, &offsets);
for ((_, note), place) in calls.into_iter().zip(placed) {
if let Some((item, offset)) = place {
self.note_calls.push((item, offset, note));
}
}
}
fn add_child(&mut self, el: XmlNode, parent: Option<usize>, layer: Option<ContentLayer>) {
let first_new = self.tree.items.len();
match el.tag_name().name() {
"h" => {
self.add_heading(el, parent, layer);
self.anchor_notes(el, first_new);
}
"p" => {
self.add_paragraph(el, parent, layer);
self.anchor_notes(el, first_new);
}
n if is_list_tag(n) => {
self.add_list(el, parent, layer, false, 1, None);
}
"table" => {
self.add_table(el, parent, layer, None, None);
}
"section" => {
self.add_children(el.children().filter(XmlNode::is_element), parent, layer)
}
"frame" => {
let charts = self.add_charts(el, parent, layer);
let images: Vec<XmlNode> = el
.descendants()
.filter(|n| n.has_tag_name("image"))
.collect();
self.add_images(&images, parent, layer, charts > 0);
}
_ => {
let images: Vec<XmlNode> = el
.descendants()
.filter(|n| n.has_tag_name("image"))
.collect();
self.add_images(&images, parent, layer, false);
}
}
}
fn item_runs(&self, item: XmlNode) -> Vec<Run> {
let mut runs = Vec::new();
let mut has_nested = false;
for child in item.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
n if is_list_tag(n) => has_nested = true,
"p" | "h" => collect_runs(child, self.styles, Fmt::default(), &mut runs),
_ => {}
}
}
let mut runs = normalize_runs(runs);
if runs.is_empty() && !has_nested {
let text = text_from_runs(&runs_of(item, self.styles));
if !text.is_empty() {
runs.push(Run {
text,
fmt: Fmt::default(),
href: None,
});
}
}
runs
}
fn add_list(
&mut self,
list: XmlNode,
parent: Option<usize>,
layer: Option<ContentLayer>,
enumerated: bool,
level: i64,
continued: Option<ListState>,
) -> Option<ListState> {
let styles = self.styles;
if !list_has_renderable(list, styles) {
return None;
}
let style_enum = level_is_enumerated(styles, list, level, enumerated);
let should_continue = continued.is_some_and(|c| c.last_item.is_some())
&& list_starts_with_empty_nested(list, styles);
if !should_continue && !list_has_direct_text(list, styles) {
for item in list_items(list) {
let (_, nested) = odf_item_content(item, styles);
for n in nested {
self.add_list(n, parent, layer, style_enum, level + 1, None);
}
}
return None;
}
let (group, current_enum, mut counter, mut previous) = match (should_continue, continued) {
(true, Some(c)) => (c.group, c.enumerated, c.counter, c.last_item),
_ => (
self.tree.add(
parent,
layer,
TreeKind::Group {
label: "list".into(),
name: "list".into(),
},
),
style_enum,
level_start(styles, list, level) - 1,
None,
),
};
for item in list_items(list) {
let (text, nested) = odf_item_content(item, styles);
let nested: Vec<XmlNode> = nested
.into_iter()
.filter(|n| list_has_renderable(*n, styles))
.collect();
if text.is_empty() && nested.is_empty() {
continue;
}
if text.is_empty() {
let nested_parent = previous.or(Some(group));
for n in nested {
self.add_list(n, nested_parent, layer, style_enum, level + 1, None);
}
continue;
}
counter += 1;
let marker = if current_enum {
let (_, suffix) = level_affixes(styles, list, level);
format!(
"{counter}{}",
if suffix.is_empty() {
"."
} else {
suffix.as_str()
}
)
} else {
String::new()
};
let first_new = self.tree.items.len();
let runs = self.item_runs(item);
let meta = ListMeta {
enumerated: current_enum,
marker,
};
let item_id = if runs.len() <= 1 {
let (t, f, h) = match runs.first() {
Some(r) => (r.text.clone(), formatting_of(r.fmt), r.href.clone()),
None => (text.clone(), None, None),
};
self.tree.add(
Some(group),
layer,
TreeKind::Text {
label: "list_item".into(),
text: t,
orig: None,
formatting: f,
hyperlink: h,
level: None,
list: Some(meta),
},
)
} else {
let id = self.tree.add(
Some(group),
layer,
TreeKind::Text {
label: "list_item".into(),
text: String::new(),
orig: None,
formatting: None,
hyperlink: None,
level: None,
list: Some(meta),
},
);
let inline = self.inline_group(Some(id), layer);
for run in &runs {
self.tree.add(
Some(inline),
layer,
text_kind(
"text",
&run.text,
formatting_of(run.fmt),
run.href.as_deref(),
),
);
}
id
};
self.anchor_notes(item, first_new);
previous = Some(item_id);
for n in nested {
self.add_list(n, Some(item_id), layer, style_enum, level + 1, None);
}
}
Some(ListState {
group,
last_item: previous,
enumerated: current_enum,
counter,
})
}
fn add_table(
&mut self,
table: XmlNode,
parent: Option<usize>,
layer: Option<ContentLayer>,
bounds: Option<(usize, usize, usize, usize)>,
prov: Option<TreeProv>,
) -> Option<usize> {
let bounds = bounds.unwrap_or_else(|| data_bounds(&grid_of(table, None)));
let (min_r, max_r, min_c, max_c) = bounds;
let grid = grid_of(table, Some(bounds));
let (height, width) = (max_r - min_r + 1, max_c - min_c + 1);
if width == 0 || height == 0 {
return None;
}
self.tables += 1;
let kind = TreeKind::Table {
table: Table {
rows: vec![vec![String::new(); width]; height],
cells: Some(Vec::new()),
..Table::default()
},
rich_cells: Vec::new(),
captions: Vec::new(),
};
let table_id = match prov {
Some(p) => self.tree.add_with_prov(parent, layer, kind, p),
None => self.tree.add(parent, layer, kind),
};
let mut cells: Vec<TableCell> = Vec::new();
let mut rich: Vec<(usize, usize, usize)> = Vec::new();
let mut positions: Vec<(&(usize, usize), &XmlNode)> = grid
.anchors
.iter()
.filter(|((r, c), _)| (min_r..=max_r).contains(r) && (min_c..=max_c).contains(c))
.collect();
positions.sort_by_key(|(pos, _)| **pos);
for (&(r, c), cell) in positions {
let (rs, cs) = (
span(*cell, "number-rows-spanned"),
span(*cell, "number-columns-spanned"),
);
let (ar, ac) = (r - min_r, c - min_c);
let text = cell_text(*cell);
if is_rich_cell(*cell, self.styles) {
let table_ix = self.tables - 1;
let group = self.tree.add(
Some(table_id),
layer,
TreeKind::Group {
label: "unspecified".into(),
name: format!("rich_cell_group_{table_ix}_{ac}_{ar}"),
},
);
for child in cell.children().filter(XmlNode::is_element) {
self.add_child(child, Some(group), layer);
}
rich.push((ar, ac, group));
}
cells.push(TableCell {
text,
bbox: None,
start_row: ar,
start_col: ac,
row_span: rs,
col_span: cs,
column_header: ar == 0,
row_header: false,
row_section: false,
});
}
if let TreeKind::Table {
table, rich_cells, ..
} = &mut self.tree.items[table_id].kind
{
for cell in &cells {
if let Some(slot) = table
.rows
.get_mut(cell.start_row)
.and_then(|r| r.get_mut(cell.start_col))
{
*slot = cell.text.clone();
}
}
table.cells = Some(cells);
*rich_cells = rich;
}
Some(table_id)
}
fn walk_slide(&mut self, page: XmlNode, slide: usize) {
let mut seen_text = false;
for el in page.children().filter(XmlNode::is_element) {
let tag = el.tag_name().name();
if tag == "notes" || tag == "par" {
continue;
}
let has_text = element_has_text(el);
let is_title = is_slide_title_element(el, !seen_text);
if has_text {
seen_text = true;
}
if tag == "frame" {
self.walk_slide_frame(el, slide, is_title);
} else {
self.walk_textbox_children(
el.children().filter(XmlNode::is_element),
slide,
is_title,
);
}
}
}
fn walk_slide_frame(&mut self, frame: XmlNode, slide: usize, is_title: bool) {
let charts = self.add_charts(frame, Some(slide), None);
let tables: Vec<XmlNode> = frame
.descendants()
.filter(|n| n.has_tag_name("table") && !n.ancestors().any(|a| a.has_tag_name("object")))
.collect();
for tbl in tables {
self.add_table(tbl, Some(slide), None, None, None);
}
let images: Vec<XmlNode> = frame
.descendants()
.filter(|n| n.has_tag_name("image"))
.collect();
self.add_images(&images, Some(slide), None, charts > 0);
let boxes: Vec<XmlNode> = frame
.descendants()
.filter(|n| n.has_tag_name("text-box"))
.collect();
for tb in boxes {
self.walk_textbox_children(tb.children().filter(XmlNode::is_element), slide, is_title);
}
}
fn walk_textbox_children<'a, 'i: 'a>(
&mut self,
els: impl Iterator<Item = XmlNode<'a, 'i>>,
slide: usize,
is_title: bool,
) {
let mut prev: Option<ListState> = None;
for el in els {
match el.tag_name().name() {
"h" => {
prev = None;
self.add_heading(el, Some(slide), None);
}
"p" => {
prev = None;
let runs = runs_of(el, self.styles);
self.add_text_runs(
&runs,
if is_title { "title" } else { "text" },
Some(slide),
None,
);
}
n if is_list_tag(n) => {
prev = self.add_list(el, Some(slide), None, false, 1, prev);
}
_ => {}
}
}
}
fn convert_sheet(
&mut self,
table: XmlNode,
group: usize,
page_no: usize,
layer: Option<ContentLayer>,
) {
let grid = grid_of(table, None);
let (min_r, max_r, min_c, max_c) = data_bounds(&grid);
let has_any = !grid.anchors.is_empty() || !grid.covered.is_empty();
if has_any {
let has_content = |r: usize, c: usize| -> bool {
(min_r..=max_r).contains(&r)
&& (min_c..=max_c).contains(&c)
&& (grid.anchors.contains_key(&(r, c)) || grid.covered.contains(&(r, c)))
};
let mut order: Vec<(usize, usize)> = grid
.anchors
.keys()
.chain(grid.covered.iter())
.copied()
.collect();
order.sort_unstable();
let mut visited: HashSet<(usize, usize)> = HashSet::new();
for (ri, ci) in order {
if visited.contains(&(ri, ci)) || !has_content(ri, ci) {
continue;
}
let mut region: HashSet<(usize, usize)> = HashSet::new();
let mut queue: VecDeque<(usize, usize)> = VecDeque::new();
queue.push_back((ri, ci));
region.insert((ri, ci));
let (mut r0, mut r1, mut c0, mut c1) = (ri, ri, ci, ci);
while let Some((r, c)) = queue.pop_front() {
r0 = r0.min(r);
r1 = r1.max(r);
c0 = c0.min(c);
c1 = c1.max(c);
for (dr, dc) in [(0i64, 1i64), (0, -1), (1, 0), (-1, 0)] {
let (nr, nc) = (r as i64 + dr, c as i64 + dc);
if nr < 0 || nc < 0 {
continue;
}
let key = (nr as usize, nc as usize);
if !region.contains(&key) && has_content(key.0, key.1) {
region.insert(key);
queue.push_back(key);
}
}
}
visited.extend(region.iter().copied());
self.add_table(
table,
Some(group),
layer,
Some((r0, r1, c0, c1)),
Some(TreeProv {
page_no,
bbox: [c0 as f64, r0 as f64, (c1 + 1) as f64, (r1 + 1) as f64],
bottom_left: false,
charspan: [0, 0],
}),
);
}
} else if !cell_has_content_at_a1(table) {
}
let images: Vec<XmlNode> = table
.descendants()
.filter(|n| n.has_tag_name("image"))
.collect();
for img in images {
let href = image_href(img).unwrap_or("");
let Some(image) = self.image_payload(img, href) else {
continue;
};
self.tree.add_with_prov(
Some(group),
layer,
TreeKind::Picture {
captions: Vec::new(),
image: Some(image),
classification: None,
confidence: None,
chart: None,
dpi: None,
},
TreeProv {
page_no,
bbox: [0.0, 0.0, 1.0, 1.0],
bottom_left: false,
charspan: [0, 0],
},
);
}
}
}
fn cell_has_content_at_a1(table: XmlNode) -> bool {
table
.descendants()
.find(|n| n.has_tag_name("table-cell"))
.is_some_and(cell_has_content)
}
#[allow(dead_code)]
fn _ods_cell_text_alias(cell: XmlNode) -> String {
ods_cell_text(cell)
}
#[cfg(test)]
mod tests {
use crate::backend::DeclarativeBackend;
use crate::{InputFormat, SourceDocument};
use docling_core::tree::{ItemTree, TreeItem, TreeKind};
use docling_core::{DoclingDocument, Node};
const NS: &str = r#"xmlns:office="o" xmlns:text="x" xmlns:table="t" xmlns:style="s" xmlns:fo="f" xmlns:draw="d" xmlns:presentation="p""#;
fn convert(xml: &str, fmt: InputFormat) -> DoclingDocument {
let src = SourceDocument::from_bytes("t.fodt", fmt, xml.as_bytes().to_vec());
super::super::odf::OdfBackend.convert(&src).unwrap()
}
fn label(it: &TreeItem) -> String {
match &it.kind {
TreeKind::Text { label, .. } => label.clone(),
TreeKind::Code { .. } => "code".into(),
TreeKind::Group { label, name } => format!("{label}:{name}"),
TreeKind::Table { .. } => "table".into(),
TreeKind::Picture { .. } => "picture".into(),
TreeKind::FieldRegion { .. } => "field_region".into(),
TreeKind::KeyValueGraph { .. } => "key_value_region".into(),
}
}
fn text(it: &TreeItem) -> &str {
match &it.kind {
TreeKind::Text { text, .. } => text,
_ => "",
}
}
#[test]
fn text_document_tree_has_upstreams_shape() {
let xml = format!(
r#"<office:document {NS}>
<office:automatic-styles>
<style:style style:name="B" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style>
<text:list-style style:name="L1"><text:list-level-style-number text:level="1" style:num-suffix=")" style:num-format="1"/></text:list-style>
</office:automatic-styles>
<office:body><office:text>
<text:h text:outline-level="2">Deep <text:span text:style-name="B">bold</text:span></text:h>
<text:p>Plain para.</text:p>
<text:p>Mixed <text:span text:style-name="B">bold</text:span> tail</text:p>
<text:list text:style-name="L1"><text:list-item><text:p>one</text:p></text:list-item><text:list-item><text:p>two</text:p></text:list-item></text:list>
<table:table><table:table-row>
<table:table-cell office:value-type="string"><text:p>plain</text:p></table:table-cell>
<table:table-cell><text:p>a</text:p><text:p>b</text:p></table:table-cell>
</table:table-row></table:table>
</office:text></office:body></office:document>"#
);
let doc = convert(&xml, InputFormat::Odt);
let t: &ItemTree = doc.tree.as_ref().expect("tree");
let labels: Vec<String> = t.items.iter().map(label).collect();
assert_eq!(
labels,
[
"section_header",
"inline:group",
"text",
"text", "text", "inline:group",
"text",
"text",
"text", "list:list",
"list_item",
"list_item",
"table",
"unspecified:rich_cell_group_0_1_0",
"text",
"text",
]
);
assert_eq!(text(&t.items[0]), "");
assert!(matches!(
&t.items[0].kind,
TreeKind::Text { level: Some(2), .. }
));
assert!(matches!(&t.items[3].kind, TreeKind::Text { formatting: Some(f), .. } if f.bold));
assert!(
matches!(&t.items[10].kind, TreeKind::Text { list: Some(l), .. } if l.enumerated && l.marker == "1)")
);
assert!(
matches!(&t.items[11].kind, TreeKind::Text { list: Some(l), .. } if l.marker == "2)")
);
let TreeKind::Table {
table, rich_cells, ..
} = &t.items[12].kind
else {
panic!()
};
assert_eq!(rich_cells, &[(0, 1, 13)]);
let cells = table.cells.as_ref().unwrap();
assert_eq!(
cells.iter().map(|c| c.text.as_str()).collect::<Vec<_>>(),
["plain", "a\nb"]
);
assert_eq!(t.items[13].parent, Some(12));
assert_eq!(t.items[14].parent, Some(13));
}
#[test]
fn presentation_tree_groups_slides() {
let xml = format!(
r#"<office:document {NS}><office:body><office:presentation>
<draw:page draw:name="First"><draw:frame presentation:class="title"><draw:text-box><text:p>Hello</text:p></draw:text-box></draw:frame>
<draw:frame><draw:text-box><text:p>Body</text:p></draw:text-box></draw:frame></draw:page>
<draw:page draw:name="Second"><draw:frame><draw:text-box><text:p>Only body</text:p></draw:text-box></draw:frame></draw:page>
</office:presentation></office:body></office:document>"#
);
let doc = convert(&xml, InputFormat::Odp);
let t = doc.tree.as_ref().expect("tree");
let labels: Vec<String> = t.items.iter().map(label).collect();
assert_eq!(
labels,
[
"chapter:slide-0",
"title",
"text",
"chapter:slide-1",
"title",
"text"
]
);
assert_eq!(text(&t.items[1]), "Hello");
assert_eq!(
text(&t.items[4]),
"Second",
"the slide name stands in for a title"
);
assert_eq!(t.body, vec![0, 3]);
}
#[test]
fn spreadsheet_tree_has_regions_with_provenance() {
let xml = format!(
r#"<office:document {NS}><office:body><office:spreadsheet>
<table:table table:name="Data">
<table:table-row><table:table-cell/><table:table-cell office:value-type="string"><text:p>Title</text:p></table:table-cell></table:table-row>
<table:table-row><table:table-cell table:number-columns-repeated="2"/></table:table-row>
<table:table-row><table:table-cell/><table:table-cell office:value-type="string"><text:p>Year</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="120"><text:p>120</text:p></table:table-cell></table:table-row>
</table:table>
</office:spreadsheet></office:body></office:document>"#
);
let doc = convert(&xml, InputFormat::Ods);
let t = doc.tree.as_ref().expect("tree");
let labels: Vec<String> = t.items.iter().map(label).collect();
assert_eq!(labels, ["section:sheet: Data", "table", "table"]);
let prov = |i: usize| {
t.items[i]
.prov
.as_ref()
.map(|p| (p.bbox, p.bottom_left, p.charspan))
};
assert_eq!(prov(1), Some(([1.0, 0.0, 2.0, 1.0], false, [0, 0])));
assert_eq!(prov(2), Some(([1.0, 2.0, 3.0, 3.0], false, [0, 0])));
let TreeKind::Table { table, .. } = &t.items[2].kind else {
panic!()
};
assert_eq!(
table
.cells
.as_ref()
.unwrap()
.iter()
.map(|c| c.text.as_str())
.collect::<Vec<_>>(),
["Year", "120"]
);
let page = doc.nodes.iter().find_map(|n| match n {
Node::PageInfo {
page_no,
width,
height,
} => Some((*page_no, *width, *height)),
_ => None,
});
assert_eq!(page, Some((1, 2.0, 3.0)), "right − left, bottom − top");
}
}