use std::collections::{HashMap, HashSet};
use docling_core::{DoclingDocument, Node, PictureImage, Table, TableStructure};
use rayon::prelude::*;
use roxmltree::{Document, Node as XmlNode};
use crate::backend::ooxml::{content_type, picture_image, resolve, Package};
use crate::backend::xlsx_drawings;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
pub struct PptxBackend;
impl DeclarativeBackend for PptxBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let mut pkg = Package::open(&source.bytes)
.ok_or_else(|| ConversionError::Parse("pptx: bad zip".into()))?;
let mut doc = DoclingDocument::new(&source.name);
let presentation = pkg
.read("ppt/presentation.xml")
.ok_or_else(|| ConversionError::Parse("pptx: no presentation.xml".into()))?;
let slide_size = slide_size(&presentation);
let content_types = pkg.read("[Content_Types].xml").unwrap_or_default();
let rid_to_part: HashMap<String, String> = pkg
.rels_for("ppt/presentation.xml")
.iter()
.map(|r| (r.id.clone(), resolve("ppt", &r.target)))
.collect();
let authors = comment_authors(&mut pkg);
let slides: Vec<(usize, String)> = slide_rids(&presentation)
.into_iter()
.enumerate()
.filter_map(|(ix, rid)| rid_to_part.get(&rid).map(|p| (ix, p.clone())))
.collect();
let frags: Vec<Option<(Vec<Node>, Vec<Node>)>> = slides
.par_iter()
.map(|(_, part)| convert_slide(pkg.clone(), part, slide_size, &content_types, &authors))
.collect();
for ((slide_ix, _), frag) in slides.into_iter().zip(frags) {
let Some((content, comments)) = frag else {
continue;
};
doc.nodes.extend(content);
if slide_ix > 0 {
doc.push(Node::PageBreak);
}
doc.nodes.extend(comments);
}
Ok(doc)
}
}
fn convert_slide(
mut pkg: Package,
part: &str,
slide_size: (i64, i64),
content_types: &str,
authors: &HashMap<String, (String, String)>,
) -> Option<(Vec<Node>, Vec<Node>)> {
let phmap = slide_placeholders(&mut pkg, part);
let dir = part
.rsplit_once('/')
.map(|(d, _)| d)
.unwrap_or("")
.to_string();
let mut valid_imgs: HashSet<String> = HashSet::new();
let mut images: HashMap<String, PictureImage> = HashMap::new();
let mut charts: HashMap<String, (String, Option<String>, docling_core::Table)> = HashMap::new();
for r in pkg.rels_for(part) {
let p = resolve(&dir, &r.target);
if r.rel_type.ends_with("/chart") {
if let Some(spec) = pkg.read(&p).as_deref().and_then(xlsx_drawings::parse_chart) {
if let Some(table) = xlsx_drawings::chart_table_from_caches(&spec) {
charts.insert(r.id.clone(), (spec.kind.to_string(), spec.title, table));
}
}
continue;
}
if !content_type(content_types, &p)
.map(|ct| ct.starts_with("image/"))
.unwrap_or(false)
{
continue;
}
valid_imgs.insert(r.id.clone());
if let Some(img) = pkg.read_bytes(&p).and_then(|b| picture_image(&p, b)) {
images.insert(r.id, img);
}
}
let xml = pkg.read(part)?;
let slide = Document::parse(&xml).ok()?;
let mut content = DoclingDocument::new("slide");
if let Some(tree) = descendant(slide.root_element(), "spTree") {
for shape in tree.children().filter(XmlNode::is_element) {
handle_shape(
shape,
&valid_imgs,
&images,
&charts,
slide_size,
&phmap,
&mut content,
);
}
}
slide_notes(&mut pkg, part, &dir, &mut content);
let mut comments = DoclingDocument::new("comments");
slide_comments(&mut pkg, part, &dir, authors, &mut comments);
Some((content.nodes, comments.nodes))
}
fn comment_authors(pkg: &mut Package) -> HashMap<String, (String, String)> {
let mut map = HashMap::new();
let Some(xml) = pkg.read("ppt/commentAuthors.xml") else {
return map;
};
let Ok(doc) = Document::parse(&xml) else {
return map;
};
for a in doc.descendants().filter(|n| n.has_tag_name("cmAuthor")) {
map.insert(
a.attribute("id").unwrap_or("").to_string(),
(
a.attribute("name").unwrap_or("").to_string(),
a.attribute("initials").unwrap_or("").to_string(),
),
);
}
map
}
fn slide_notes(pkg: &mut Package, part: &str, dir: &str, doc: &mut DoclingDocument) {
for r in pkg.rels_for(part) {
if !r.rel_type.ends_with("/notesSlide") {
continue;
}
let p = resolve(dir, &r.target);
let Some(xml) = pkg.read(&p) else {
continue;
};
let Ok(ndoc) = Document::parse(&xml) else {
continue;
};
let body = ndoc.descendants().find(|n| {
n.has_tag_name("sp")
&& n.descendants()
.any(|d| d.has_tag_name("ph") && d.attribute("type") == Some("body"))
});
let Some(tx) = body.and_then(|sp| descendant(sp, "txBody")) else {
continue;
};
let text = tx
.children()
.filter(|n| n.has_tag_name("p"))
.map(|p| {
let mut s = String::new();
for child in p.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
"r" | "fld" => {
if let Some(t) = child.children().find(|n| n.has_tag_name("t")) {
s.push_str(t.text().unwrap_or(""));
}
}
"br" => s.push('\u{b}'),
_ => {}
}
}
s
})
.collect::<Vec<_>>()
.join("\n");
let text = text.trim();
if !text.is_empty() {
doc.push(Node::Furniture {
layer: docling_core::ContentLayer::Notes,
inner: Box::new(Node::Located {
location: [0, 0, 0, 0],
inner: Box::new(Node::Paragraph {
text: text.to_string(),
}),
}),
});
}
}
}
fn slide_comments(
pkg: &mut Package,
part: &str,
dir: &str,
authors: &HashMap<String, (String, String)>,
doc: &mut DoclingDocument,
) {
for r in pkg.rels_for(part) {
if !r.rel_type.ends_with("/comments") {
continue;
}
let p = resolve(dir, &r.target);
let Some(xml) = pkg.read(&p) else {
continue;
};
let Ok(cdoc) = Document::parse(&xml) else {
continue;
};
for cm in cdoc.descendants().filter(|n| n.has_tag_name("cm")) {
let text = cm
.children()
.find(|n| n.has_tag_name("text"))
.and_then(|t| t.text())
.unwrap_or("")
.trim();
if text.is_empty() {
continue;
}
let mut meta = Vec::new();
if let Some((name, initials)) = authors.get(cm.attribute("authorId").unwrap_or("")) {
if !name.is_empty() {
let mut a = format!("author: {name}");
if !initials.is_empty() {
a.push_str(&format!(" ({initials})"));
}
meta.push(a);
}
}
if let Some(dt) = cm.attribute("dt").filter(|d| !d.is_empty()) {
meta.push(format!("time: {dt}"));
}
let full = if meta.is_empty() {
text.to_string()
} else {
format!("[{}]: {}", meta.join(", "), text)
};
doc.push(Node::Furniture {
layer: docling_core::ContentLayer::Notes,
inner: Box::new(Node::Paragraph { text: full }),
});
}
}
}
fn slide_rids(presentation: &str) -> Vec<String> {
let Ok(doc) = Document::parse(presentation) else {
return Vec::new();
};
doc.descendants()
.filter(|n| n.has_tag_name("sldId"))
.filter_map(|n| {
n.attributes()
.find(|a| a.name() == "id" && a.namespace().is_some())
.map(|a| a.value().to_string())
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn handle_shape(
shape: XmlNode,
valid_imgs: &HashSet<String>,
images: &HashMap<String, PictureImage>,
charts: &HashMap<String, (String, Option<String>, docling_core::Table)>,
slide_size: (i64, i64),
phmap: &PhMap,
doc: &mut DoclingDocument,
) {
match shape.tag_name().name() {
"grpSp" => {
for child in shape.children().filter(XmlNode::is_element) {
handle_shape(child, valid_imgs, images, charts, slide_size, phmap, doc);
}
}
"graphicFrame" => {
if let Some(tbl) = descendant(shape, "tbl") {
if let Some(table) = parse_table(tbl) {
push_located(
doc,
shape_location(shape, slide_size, phmap),
Node::Table(table),
);
}
} else if let Some((kind, title, table)) = descendant(shape, "chart")
.and_then(|c| c.attributes().find(|a| a.name() == "id"))
.and_then(|a| charts.get(a.value()))
{
doc.push(Node::Chart {
kind: kind.clone(),
table: table.clone(),
caption: title.clone(),
location: Some(shape_location(shape, slide_size, phmap)),
});
}
}
"pic" => {
let embedded = descendant(shape, "blip").and_then(|b| {
b.attributes()
.find(|a| a.name() == "embed")
.map(|a| a.value().to_string())
});
if let Some(rid) = embedded.filter(|rid| valid_imgs.contains(rid)) {
push_located(
doc,
shape_location(shape, slide_size, phmap),
Node::Picture {
caption: None,
image: images.get(&rid).cloned(),
classification: None,
},
);
}
}
"sp" => handle_text_shape(shape, shape_location(shape, slide_size, phmap), doc),
_ => {}
}
}
fn slide_size(presentation: &str) -> (i64, i64) {
Document::parse(presentation)
.ok()
.and_then(|d| {
let sz = d.descendants().find(|n| n.has_tag_name("sldSz"))?;
Some((
sz.attribute("cx")?.parse().ok()?,
sz.attribute("cy")?.parse().ok()?,
))
})
.unwrap_or((9144000, 6858000))
}
fn shape_location(shape: XmlNode, (w, h): (i64, i64), phmap: &PhMap) -> [u16; 4] {
let geom = xfrm_geom(shape).or_else(|| inherited_geom(shape, phmap));
let (left, top, cw, ch) = match geom {
Some([x, y, cx, cy]) if x != 0 => (x, y, cx, cy),
_ => (0, 0, w, h),
};
let n = |v: i64, dim: i64| -> u16 {
if dim == 0 {
return 0;
}
((512.0 * v as f64 / dim as f64).round() as i64).clamp(0, 511) as u16
};
[
n(left, w),
n(h - (top + ch), h),
n(left + cw, w),
n(h - top, h),
]
}
fn xfrm_geom(node: XmlNode) -> Option<[i64; 4]> {
let x = descendant(node, "xfrm")?;
let off = x.children().find(|n| n.has_tag_name("off"))?;
let ext = x.children().find(|n| n.has_tag_name("ext"))?;
Some([
off.attribute("x")?.parse().ok()?,
off.attribute("y")?.parse().ok()?,
ext.attribute("cx")?.parse().ok()?,
ext.attribute("cy")?.parse().ok()?,
])
}
fn inherited_geom(shape: XmlNode, phmap: &PhMap) -> Option<[i64; 4]> {
let ph = descendant(shape, "ph")?;
if let Some(idx) = ph.attribute("idx") {
if let Some(g) = phmap.by_idx.get(idx) {
return Some(*g);
}
}
if let Some(t) = ph.attribute("type") {
if let Some(g) = phmap.by_type.get(t) {
return Some(*g);
}
}
None
}
#[derive(Default)]
struct PhMap {
by_idx: HashMap<String, [i64; 4]>,
by_type: HashMap<String, [i64; 4]>,
}
fn slide_placeholders(pkg: &mut Package, slide_part: &str) -> PhMap {
let mut map = PhMap::default();
let slide_dir = slide_part.rsplit_once('/').map_or("", |(d, _)| d);
let Some(layout_part) = rel_target(pkg, slide_part, slide_dir, "/slideLayout") else {
return map;
};
if let Some(xml) = pkg.read(&layout_part) {
collect_placeholders(&xml, &mut map);
}
let layout_dir = layout_part.rsplit_once('/').map_or("", |(d, _)| d);
if let Some(master_part) = rel_target(pkg, &layout_part, layout_dir, "/slideMaster") {
if let Some(xml) = pkg.read(&master_part) {
collect_placeholders(&xml, &mut map);
}
}
map
}
fn rel_target(pkg: &mut Package, part: &str, base_dir: &str, suffix: &str) -> Option<String> {
pkg.rels_for(part)
.iter()
.find(|r| r.rel_type.ends_with(suffix))
.map(|r| resolve(base_dir, &r.target))
}
fn collect_placeholders(xml: &str, map: &mut PhMap) {
let Ok(doc) = Document::parse(xml) else {
return;
};
let Some(tree) = descendant(doc.root_element(), "spTree") else {
return;
};
for sp in tree.children().filter(|n| n.has_tag_name("sp")) {
let Some(ph) = descendant(sp, "ph") else {
continue;
};
let Some(geom) = xfrm_geom(sp) else {
continue;
};
if let Some(idx) = ph.attribute("idx") {
map.by_idx.entry(idx.to_string()).or_insert(geom);
}
if let Some(t) = ph.attribute("type") {
map.by_type.entry(t.to_string()).or_insert(geom);
}
}
}
fn push_located(doc: &mut DoclingDocument, location: [u16; 4], node: Node) {
doc.push(Node::Located {
location,
inner: Box::new(node),
});
}
#[derive(Clone, Copy, PartialEq)]
enum Placeholder {
Title,
Subtitle,
Body,
TextBox,
}
fn placeholder_kind(sp: XmlNode) -> Placeholder {
match descendant(sp, "ph") {
None => Placeholder::TextBox,
Some(ph) => match ph.attribute("type") {
Some("title") | Some("ctrTitle") => Placeholder::Title,
Some("subTitle") => Placeholder::Subtitle,
_ => Placeholder::Body,
},
}
}
fn handle_text_shape(sp: XmlNode, location: [u16; 4], doc: &mut DoclingDocument) {
let Some(tx_body) = descendant(sp, "txBody") else {
return;
};
let kind = placeholder_kind(sp);
let paragraphs: Vec<XmlNode> = tx_body.children().filter(|n| n.has_tag_name("p")).collect();
if paragraphs
.iter()
.all(|p| paragraph_text(*p).trim().is_empty())
{
return;
}
let mut in_list = false;
let mut number = 0u64;
for para in paragraphs {
let text = paragraph_text(para);
match list_kind(para, kind) {
Some(numbered) => {
if !in_list {
in_list = true;
number = 0;
}
let n = if numbered {
number += 1;
number
} else {
0
};
doc.push(Node::ListItem {
ordered: numbered,
number: n,
first_in_list: false,
text,
level: 0,
marker: numbered.then(|| format!("{n}.")),
location: Some(location),
dclx: None,
href: None,
layer: None,
});
}
None => {
in_list = false;
match kind {
Placeholder::Title => {
push_located(doc, location, Node::Heading { level: 1, text })
}
_ => push_located(doc, location, Node::Paragraph { text }),
}
}
}
}
}
fn list_kind(para: XmlNode, placeholder: Placeholder) -> Option<bool> {
if let Some(p_pr) = para.children().find(|n| n.has_tag_name("pPr")) {
if p_pr.children().any(|n| n.has_tag_name("buNone")) {
return None;
}
if p_pr.children().any(|n| n.has_tag_name("buAutoNum")) {
return Some(true);
}
if p_pr.children().any(|n| n.has_tag_name("buChar")) {
return Some(false);
}
}
match placeholder {
Placeholder::Body => Some(false),
_ => None,
}
}
fn paragraph_text(para: XmlNode) -> String {
let mut out = String::new();
for child in para.children().filter(XmlNode::is_element) {
match child.tag_name().name() {
"r" | "fld" => {
if let Some(t) = child.children().find(|n| n.has_tag_name("t")) {
out.push_str(t.text().unwrap_or(""));
}
}
"br" => out.push(' '),
_ => {}
}
}
out
}
fn parse_table(tbl: XmlNode) -> Option<Table> {
let rows: Vec<XmlNode> = tbl.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")).count())
.max()
.unwrap_or(0);
if rows.is_empty() || num_cols == 0 {
return None;
}
let first_row_header = descendant(tbl, "tblPr")
.and_then(|p| p.attribute("firstRow"))
.map(|v| v == "1" || v == "true")
.unwrap_or(false);
let mut grid = vec![vec![String::new(); num_cols]; rows.len()];
let mut col_continuation = vec![vec![false; num_cols]; rows.len()];
let mut row_continuation = vec![vec![false; num_cols]; rows.len()];
for (ri, row) in rows.iter().enumerate() {
let cells: Vec<XmlNode> = row.children().filter(|n| n.has_tag_name("tc")).collect();
for (ci, tc) in cells.iter().enumerate().take(num_cols) {
let h = tc.attribute("hMerge").is_some();
let v = tc.attribute("vMerge").is_some();
col_continuation[ri][ci] = h;
row_continuation[ri][ci] = v;
if h || v {
continue;
}
let text = cell_text(*tc);
let span = |name: &str| -> usize {
tc.attribute(name).and_then(|s| s.parse().ok()).unwrap_or(1)
};
let row_end = (ri + span("rowSpan")).min(rows.len());
let col_end = (ci + span("gridSpan")).min(num_cols);
for grow in grid.iter_mut().take(row_end).skip(ri) {
for cell in grow.iter_mut().take(col_end).skip(ci) {
*cell = text.clone();
}
}
}
}
let header_row = (0..rows.len())
.map(|ri| first_row_header && ri == 0)
.collect();
Some(Table {
rows: grid,
location: None,
structure: Some(TableStructure {
header_row,
col_continuation,
row_continuation,
row_header: Vec::new(),
col_header: Vec::new(),
}),
cell_blocks: None,
})
}
fn cell_text(tc: XmlNode) -> String {
let Some(tx_body) = descendant(tc, "txBody") else {
return String::new();
};
tx_body
.children()
.filter(|n| n.has_tag_name("p"))
.map(|p| paragraph_text(p))
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
fn descendant<'a, 'input>(node: XmlNode<'a, 'input>, name: &str) -> Option<XmlNode<'a, 'input>> {
node.descendants().find(|n| n.has_tag_name(name))
}
#[cfg(test)]
mod chart_tests {
use super::*;
use crate::backend::DeclarativeBackend;
use crate::{InputFormat, SourceDocument};
#[test]
fn native_chart_yields_classified_data_grid() {
let path = format!(
"{}/tests/data/pptx/sources/pptx_chart.pptx",
env!("CARGO_MANIFEST_DIR")
);
let bytes = std::fs::read(&path).expect("fixture exists");
let src = SourceDocument::from_bytes("c.pptx", InputFormat::Pptx, bytes);
let doc = PptxBackend.convert(&src).expect("converts");
let chart = doc
.nodes
.iter()
.find_map(|n| match n {
Node::Chart {
kind,
table,
caption,
..
} => Some((kind.clone(), table.clone(), caption.clone())),
_ => None,
})
.expect("a chart node");
assert_eq!(chart.0, "bar_chart");
assert_eq!(
chart.2.as_deref(),
Some("Wild Duck Observations by Year"),
"chart title as caption"
);
assert_eq!(chart.1.rows[0][1], "Freshwater Ducks");
assert_eq!(chart.1.rows[1], vec!["2019", "120", "80"]);
}
}