use docling_core::{DoclingDocument, Node, Table};
use crate::backend::cfb::CompoundFile;
use crate::backend::officeart::Records;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
const RT_DOCUMENT: u16 = 0x03E8; const RT_SLIDE: u16 = 0x03EE; const RT_SLIDE_LIST_WITH_TEXT: u16 = 0x0FF0;
const RT_SLIDE_PERSIST_ATOM: u16 = 0x03F3;
const RT_TEXT_HEADER_ATOM: u16 = 0x0F9F;
const RT_OUTLINE_TEXT_REF_ATOM: u16 = 0x0F9E;
const RT_TEXT_CHARS_ATOM: u16 = 0x0FA0;
const RT_TEXT_BYTES_ATOM: u16 = 0x0FA8;
const RT_STYLE_TEXT_PROP_ATOM: u16 = 0x0FA1;
const RT_STYLE_TEXT_PROP9_ATOM: u16 = 0x0FAC;
const RT_BINARY_TAG_DATA: u16 = 0x138B;
const RT_MAIN_MASTER: u16 = 0x03F8;
const RT_SLIDE_ATOM: u16 = 0x03EF;
const RT_TEXT_MASTER_STYLE_ATOM: u16 = 0x0FA3;
const OA_CLIENT_DATA: u16 = 0xF011;
const OA_DG_CONTAINER: u16 = 0xF002;
const OA_SPGR_CONTAINER: u16 = 0xF003;
const OA_SP_CONTAINER: u16 = 0xF004;
const OA_FSPGR: u16 = 0xF009;
const OA_CHILD_ANCHOR: u16 = 0xF00F;
const OA_CLIENT_ANCHOR: u16 = 0xF010;
const OA_CLIENT_TEXTBOX: u16 = 0xF00D;
const TX_TITLE: u32 = 0;
const TX_CENTER_TITLE: u32 = 6;
pub struct PptBackend;
impl DeclarativeBackend for PptBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let cfb = CompoundFile::open(&source.bytes).ok_or_else(|| {
ConversionError::Parse(CompoundFile::open_error("ppt", &source.bytes))
})?;
let stream = cfb.stream("PowerPoint Document").ok_or_else(|| {
ConversionError::Parse(cfb.stream_error("ppt", "PowerPoint Document"))
})?;
let current_user = cfb.stream("Current User").unwrap_or_default();
let edits = UserEdits::read(¤t_user, &stream);
let encrypted = match &edits {
Some(edits) => edits.encrypted(),
None => cfb.stream("EncryptedSummary").is_some(),
};
if encrypted {
return Err(crate::backend::offcrypto::encrypted("ppt"));
}
let mut slwt: Vec<Vec<TextBlock>> = Vec::new();
for (header, body) in Records::new(&stream) {
if header.rec_type != RT_DOCUMENT {
continue;
}
for (h2, b2) in Records::new(body) {
if h2.rec_type == RT_SLIDE_LIST_WITH_TEXT && h2.instance == 0 {
collect_slwt_slides(b2, &mut slwt);
}
}
}
let masters = master_styles(&stream, edits.as_ref());
let fallback = Records::new(&stream)
.find(|(h, _)| h.rec_type == RT_MAIN_MASTER)
.map(|(_, body)| MasterStyles::read(body))
.unwrap_or_default();
let slide_shapes: Vec<(Vec<ShapeItem>, &MasterStyles)> = Records::new(&stream)
.filter(|(h, _)| h.rec_type == RT_SLIDE)
.map(|(_, body)| {
let master = master_id_ref(body)
.and_then(|id| masters.get(&id))
.unwrap_or(&fallback);
(slide_items(body), master)
})
.collect();
let n = slwt.len().max(slide_shapes.len());
let mut doc = DoclingDocument::new(&source.name);
let mut first = true;
for i in 0..n {
let blocks = slwt.get(i).cloned().unwrap_or_default();
let (shapes, master) = slide_shapes
.get(i)
.map_or((Vec::new(), &fallback), |(s, m)| (s.clone(), *m));
let nodes = assemble_slide(blocks, shapes, master);
if nodes.is_empty() {
continue;
}
if !first {
doc.push(Node::PageBreak);
}
first = false;
for node in nodes {
doc.push(node);
}
}
Ok(doc)
}
}
fn master_id_ref(slide_body: &[u8]) -> Option<u32> {
Records::new(slide_body)
.find(|(h, _)| h.rec_type == RT_SLIDE_ATOM)
.and_then(|(_, b)| Some(u32::from_le_bytes(b.get(12..16)?.try_into().ok()?)))
}
fn master_styles(
stream: &[u8],
edits: Option<&UserEdits>,
) -> std::collections::HashMap<u32, MasterStyles> {
let mut styles = std::collections::HashMap::new();
let Some(edits) = edits else {
return styles;
};
let mut title_masters = Vec::new();
for (header, body) in Records::new(stream) {
if header.rec_type != RT_DOCUMENT {
continue;
}
for (h, list) in Records::new(body) {
if h.rec_type != RT_SLIDE_LIST_WITH_TEXT || h.instance != 1 {
continue;
}
for (h2, atom) in Records::new(list) {
if h2.rec_type != RT_SLIDE_PERSIST_ATOM || atom.len() < 16 {
continue;
}
let persist = u32::from_le_bytes(atom[0..4].try_into().unwrap());
let id = u32::from_le_bytes(atom[12..16].try_into().unwrap());
let Some(&off) = edits.persist.get(&persist) else {
continue;
};
let Some((h3, master)) = stream
.get(off as usize..)
.and_then(|d| Records::new(d).next())
else {
continue;
};
match h3.rec_type {
RT_MAIN_MASTER => {
styles.insert(id, MasterStyles::read(master));
}
RT_SLIDE => title_masters.extend(master_id_ref(master).map(|m| (id, m))),
_ => {}
}
}
}
}
for (id, main) in title_masters {
if let Some(main) = styles.get(&main).cloned() {
styles.insert(id, main);
}
}
styles
}
const RT_USER_EDIT_ATOM: u16 = 0x0FF5;
const RT_PERSIST_DIRECTORY_ATOM: u16 = 0x1772;
const RT_CRYPT_SESSION10_CONTAINER: u16 = 0x2F14;
pub(crate) struct UserEdits {
persist: std::collections::HashMap<u32, u32>,
pub(crate) objects: Vec<(u32, u32)>,
encrypt_ref: Option<u32>,
encrypt_ref_at: Option<usize>,
}
impl UserEdits {
pub(crate) fn read(current_user: &[u8], doc: &[u8]) -> Option<Self> {
let u32_at =
|d: &[u8], o: usize| Some(u32::from_le_bytes(d.get(o..o + 4)?.try_into().ok()?));
let mut off = u32_at(current_user, 16)? as usize;
let mut edits = Self {
persist: std::collections::HashMap::new(),
objects: Vec::new(),
encrypt_ref: None,
encrypt_ref_at: None,
};
for hop in 0..doc.len() / 40 + 1 {
let (header, body) = Records::new(doc.get(off..)?).next()?;
if header.rec_type != RT_USER_EDIT_ATOM || body.len() < 28 {
return (hop > 0).then_some(edits);
}
if hop == 0 && body.len() >= 32 {
edits.encrypt_ref = u32_at(body, 28);
edits.encrypt_ref_at = Some(off + 8 + 28);
}
let dir_off = u32_at(body, 12)? as usize;
if let Some((h, dir)) = doc.get(dir_off..).and_then(|d| Records::new(d).next()) {
if h.rec_type == RT_PERSIST_DIRECTORY_ATOM {
let mut p = 0;
while let Some(v) = u32_at(dir, p) {
let (id, count) = (v & 0xF_FFFF, (v >> 20) as usize);
for k in 0..count {
let Some(o) = u32_at(dir, p + 4 + k * 4) else {
break;
};
edits.persist.entry(id + k as u32).or_insert(o);
edits.objects.push((id + k as u32, o));
}
p += 4 + count * 4;
}
}
}
match u32_at(body, 8)? as usize {
0 => break,
prev => off = prev,
}
}
Some(edits)
}
pub(crate) fn crypt_session<'a>(&self, doc: &'a [u8]) -> Option<&'a [u8]> {
let off = *self.persist.get(&self.encrypt_ref?)? as usize;
let (header, body) = Records::new(doc.get(off..)?).next()?;
(header.rec_type == RT_CRYPT_SESSION10_CONTAINER).then_some(body)
}
pub(crate) fn session_offset(&self) -> Option<usize> {
self.persist.get(&self.encrypt_ref?).map(|&o| o as usize)
}
pub(crate) fn encrypt_ref_offset(&self) -> Option<usize> {
self.encrypt_ref_at
}
pub(crate) fn encrypted(&self) -> bool {
self.encrypt_ref.is_some_and(|r| r != 0)
}
}
#[derive(Clone, Default)]
struct TextBlock {
is_title: bool,
text_type: u32,
text: String,
styles: Vec<ParaStyle>,
consumed: bool,
}
#[derive(Clone, Copy, Default)]
struct ParaStyle {
count: usize,
indent: u8,
bullet: Option<bool>,
autonum: Option<(u16, u16)>,
}
fn parse_para_styles(body: &[u8], text_len: usize) -> Vec<ParaStyle> {
let mut out = Vec::new();
let mut pos = 0usize;
let mut covered = 0usize;
while covered <= text_len && pos + 10 <= body.len() {
let count =
u32::from_le_bytes([body[pos], body[pos + 1], body[pos + 2], body[pos + 3]]) as usize;
let indent = u16::from_le_bytes([body[pos + 4], body[pos + 5]]);
let Some((next, bullet)) = pf_exception(body, pos + 6) else {
break;
};
pos = next;
covered += count;
out.push(ParaStyle {
count,
indent: indent.min(u8::MAX as u16) as u8,
bullet,
autonum: None,
});
}
out
}
fn pf_exception(body: &[u8], mut pos: usize) -> Option<(usize, Option<bool>)> {
let u16_at = |p: usize| Some(u16::from_le_bytes(body.get(p..p + 2)?.try_into().ok()?));
let masks = u32::from_le_bytes(body.get(pos..pos + 4)?.try_into().ok()?);
pos += 4;
let mut bullet = None;
if masks & 0x0000_000F != 0 {
let flags = u16_at(pos)?;
if masks & 0x0000_0001 != 0 {
bullet = Some(flags & 0x01 != 0);
}
pos += 2;
}
for (bit, size) in [
(0x0000_0080u32, 2usize), (0x0000_0010, 2), (0x0000_0040, 2), (0x0000_0020, 4), (0x0000_0800, 2), (0x0000_1000, 2), (0x0000_2000, 2), (0x0000_4000, 2), (0x0000_0100, 2), (0x0000_0400, 2), (0x0000_8000, 2), ] {
if masks & bit != 0 {
pos += size;
}
}
if masks & 0x0010_0000 != 0 {
pos += 2 + u16_at(pos)? as usize * 4;
}
for (bit, size) in [
(0x0001_0000u32, 2usize), (0x000E_0000, 2), (0x0020_0000, 2), ] {
if masks & bit != 0 {
pos += size;
}
}
(pos <= body.len()).then_some((pos, bullet))
}
fn cf_exception(body: &[u8], mut pos: usize) -> Option<usize> {
let masks = u32::from_le_bytes(body.get(pos..pos + 4)?.try_into().ok()?);
pos += 4;
if masks & 0x0000_FFFF != 0 {
pos += 2; }
for (bit, size) in [
(0x0001_0000u32, 2usize), (0x0020_0000, 2), (0x0040_0000, 2), (0x0080_0000, 2), (0x0002_0000, 2), (0x0004_0000, 4), (0x0008_0000, 2), (0x0010_0000, 4), (0x0100_0000, 2), (0x0200_0000, 2), (0x0400_0000, 4), ] {
if masks & bit != 0 {
pos += size;
}
}
(pos <= body.len()).then_some(pos)
}
#[derive(Clone, Default)]
struct MasterStyles {
types: std::collections::HashMap<u32, [Option<bool>; 5]>,
}
impl MasterStyles {
fn read(master: &[u8]) -> Self {
let mut styles = Self::default();
for (h, b) in Records::new(master) {
if h.rec_type != RT_TEXT_MASTER_STYLE_ATOM {
continue;
}
let inst = h.instance as u32;
let mut levels = [None; 5];
let Some(n) = b.get(..2).map(|x| u16::from_le_bytes([x[0], x[1]])) else {
continue;
};
let mut pos = 2;
for i in 0..n.min(5) as usize {
let level = if inst >= 5 {
let Some(l) = b.get(pos..pos + 2) else { break };
pos += 2;
u16::from_le_bytes([l[0], l[1]]) as usize
} else {
i
};
let Some((next, bullet)) = pf_exception(b, pos) else {
break;
};
let Some(next) = cf_exception(b, next) else {
break;
};
pos = next;
if let Some(slot) = levels.get_mut(level) {
*slot = bullet;
}
}
styles.types.insert(inst, levels);
}
styles
}
fn bullet(&self, text_type: u32, indent: u8) -> bool {
let parent = match text_type {
5 | 7 | 8 => Some(1),
6 => Some(0),
_ => None,
};
let level = (indent as usize).min(4);
let own = self.types.get(&text_type);
match parent {
Some(base) => own
.and_then(|l| l[level])
.unwrap_or_else(|| self.bullet(base, indent)),
None => own
.and_then(|l| l[..=level].iter().rev().find_map(|b| *b))
.unwrap_or(false),
}
}
}
fn parse_para_styles9(body: &[u8]) -> Vec<Option<(u16, u16)>> {
let mut out = Vec::new();
let mut pos = 0usize;
while pos + 4 <= body.len() {
let masks = u32::from_le_bytes([body[pos], body[pos + 1], body[pos + 2], body[pos + 3]]);
pos += 4;
let mut has_auto = false;
let mut scheme_start = None;
if masks & 0x0080_0000 != 0 {
pos += 2; }
if masks & 0x0100_0000 != 0 {
has_auto = body.get(pos).is_some_and(|&b| b != 0);
pos += 2; }
if masks & 0x0200_0000 != 0 {
if pos + 4 > body.len() {
break;
}
let scheme = u16::from_le_bytes([body[pos], body[pos + 1]]);
let start = u16::from_le_bytes([body[pos + 2], body[pos + 3]]);
scheme_start = Some((scheme, start.max(1)));
pos += 4;
}
if masks & !0x0380_0000 != 0 {
break;
}
let Some(cf) = body.get(pos..pos + 4) else {
break;
};
if cf != [0, 0, 0, 0] {
break;
}
pos += 4;
let Some(si) = body.get(pos..pos + 4) else {
break;
};
if si != [0, 0, 0, 0] {
break;
}
pos += 4;
out.push(
(has_auto || scheme_start.is_some())
.then_some(scheme_start)
.flatten(),
);
}
out
}
fn collect_slwt_slides(body: &[u8], slides: &mut Vec<Vec<TextBlock>>) {
for (h, b) in Records::new(body) {
match h.rec_type {
RT_SLIDE_PERSIST_ATOM => slides.push(Vec::new()),
RT_TEXT_HEADER_ATOM => {
let tx = b
.get(..4)
.map(|x| u32::from_le_bytes([x[0], x[1], x[2], x[3]]))
.unwrap_or(u32::MAX);
if let Some(slide) = slides.last_mut() {
slide.push(TextBlock {
is_title: tx == TX_TITLE || tx == TX_CENTER_TITLE,
text_type: tx,
..TextBlock::default()
});
}
}
RT_TEXT_CHARS_ATOM => {
if let Some(block) = slides.last_mut().and_then(|s| s.last_mut()) {
block.text.push_str(&utf16_text(b));
}
}
RT_TEXT_BYTES_ATOM => {
if let Some(block) = slides.last_mut().and_then(|s| s.last_mut()) {
block.text.push_str(&bytes_text(b));
}
}
RT_STYLE_TEXT_PROP_ATOM => {
if let Some(block) = slides.last_mut().and_then(|s| s.last_mut()) {
let len = block.text.chars().count();
block.styles.extend(parse_para_styles(b, len));
}
}
_ => {}
}
}
}
#[derive(Clone, Default)]
struct ShapeText {
is_title: bool,
text_type: u32,
text: String,
styles: Vec<ParaStyle>,
outline_ref: Option<u32>,
}
type Anchor = (i32, i32, i32, i32);
#[derive(Clone)]
enum ShapeItem {
Text {
anchor: Option<Anchor>,
text: ShapeText,
},
Table {
table: Table,
},
}
fn slide_items(slide_body: &[u8]) -> Vec<ShapeItem> {
let Some(dg) = find_container(slide_body, OA_DG_CONTAINER, 0) else {
return Vec::new();
};
let mut units = Vec::new();
for (h, b) in Records::new(dg) {
if h.rec_type == OA_SPGR_CONTAINER {
for (h2, b2) in Records::new(b) {
match h2.rec_type {
OA_SP_CONTAINER if !has_record(b2, OA_FSPGR) => {
if let Some(item) = shape_item(b2) {
units.push((shape_anchor(b2), vec![item]));
}
}
OA_SPGR_CONTAINER => {
let mut items = Vec::new();
group_items(b2, &mut items, 0);
units.push((group_anchor(b2), items));
}
_ => {}
}
}
}
}
by_position(units)
}
fn group_anchor(group_body: &[u8]) -> Option<Anchor> {
Records::new(group_body)
.find(|(h, b)| h.rec_type == OA_SP_CONTAINER && has_record(b, OA_FSPGR))
.and_then(|(_, b)| shape_anchor(b))
}
fn by_position(units: Vec<(Option<Anchor>, Vec<ShapeItem>)>) -> Vec<ShapeItem> {
const ROW_TOLERANCE_TENTHS: i64 = 288;
let mut keyed: Vec<(i64, i64, usize, Vec<ShapeItem>)> = units
.into_iter()
.enumerate()
.map(|(index, (anchor, items))| {
let (left, top) =
anchor.map_or((i64::MAX, i64::MAX), |(l, t, _, _)| (l as i64, t as i64));
(top, left, index, items)
})
.collect();
keyed.sort_by_key(|&(top, _, index, _)| (top, index));
let mut out = Vec::new();
let mut row: Vec<(i64, usize, Vec<ShapeItem>)> = Vec::new();
let mut prev_top: Option<i64> = None;
let flush = |row: &mut Vec<(i64, usize, Vec<ShapeItem>)>, out: &mut Vec<ShapeItem>| {
row.sort_by_key(|&(left, index, _)| (left, index));
out.extend(row.drain(..).flat_map(|(_, _, items)| items));
};
for (top, left, index, items) in keyed {
if prev_top.is_some_and(|p| top.saturating_sub(p).saturating_mul(10) > ROW_TOLERANCE_TENTHS)
{
flush(&mut row, &mut out);
}
prev_top = Some(top);
row.push((left, index, items));
}
flush(&mut row, &mut out);
out
}
fn group_items(group_body: &[u8], out: &mut Vec<ShapeItem>, depth: usize) {
if depth > 16 {
return;
}
let mut cells: Vec<(Anchor, ShapeText)> = Vec::new();
let mut children = Vec::new();
for (h, b) in Records::new(group_body) {
match h.rec_type {
OA_SP_CONTAINER => {
if has_record(b, OA_FSPGR) {
continue;
}
if let Some(ShapeItem::Text { anchor, text }) = shape_item(b) {
if let Some((l, t, r, b)) = anchor {
if (r - l).abs() > 1 && (b - t).abs() > 1 {
cells.push(((l, t, r, b), text.clone()));
}
}
children.push((anchor, vec![ShapeItem::Text { anchor, text }]));
}
}
OA_SPGR_CONTAINER => {
let mut items = Vec::new();
group_items(b, &mut items, depth + 1);
children.push((group_anchor(b), items));
}
_ => {}
}
}
if let Some(table) = grid_table(&cells) {
out.push(ShapeItem::Table { table });
} else {
out.extend(by_position(children));
}
}
fn shape_item(sp_body: &[u8]) -> Option<ShapeItem> {
let anchor = shape_anchor(sp_body);
let mut text = ShapeText::default();
let mut autonums: Vec<Option<(u16, u16)>> = Vec::new();
for (h, b) in Records::new(sp_body) {
match h.rec_type {
OA_CLIENT_TEXTBOX => {
for (h2, b2) in Records::new(b) {
match h2.rec_type {
RT_TEXT_HEADER_ATOM => {
let tx = b2
.get(..4)
.map(|x| u32::from_le_bytes([x[0], x[1], x[2], x[3]]))
.unwrap_or(u32::MAX);
text.is_title = tx == TX_TITLE || tx == TX_CENTER_TITLE;
text.text_type = tx;
}
RT_OUTLINE_TEXT_REF_ATOM => {
text.outline_ref = b2
.get(..4)
.map(|x| u32::from_le_bytes([x[0], x[1], x[2], x[3]]));
}
RT_TEXT_CHARS_ATOM => text.text.push_str(&utf16_text(b2)),
RT_TEXT_BYTES_ATOM => text.text.push_str(&bytes_text(b2)),
RT_STYLE_TEXT_PROP_ATOM => {
let len = text.text.chars().count();
text.styles.extend(parse_para_styles(b2, len));
}
_ => {}
}
}
}
OA_CLIENT_DATA => {
if let Some(blob) = find_container(b, RT_BINARY_TAG_DATA, 0) {
for (h3, b3) in Records::new(blob) {
if h3.rec_type == RT_STYLE_TEXT_PROP9_ATOM {
autonums = parse_para_styles9(b3);
}
}
}
}
_ => {}
}
}
for (style, auto) in text.styles.iter_mut().zip(autonums) {
style.autonum = auto;
}
Some(ShapeItem::Text { anchor, text })
}
fn shape_anchor(sp_body: &[u8]) -> Option<Anchor> {
for (h, b) in Records::new(sp_body) {
match h.rec_type {
OA_CHILD_ANCHOR if b.len() >= 16 => {
let v: Vec<i32> = b[..16]
.chunks_exact(4)
.map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
return Some((v[0], v[1], v[2], v[3])); }
OA_CLIENT_ANCHOR if b.len() >= 8 => {
if b.len() >= 16 {
let v: Vec<i32> = b[..16]
.chunks_exact(4)
.map(|c| i32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
return Some((v[1], v[0], v[3], v[2])); }
let v: Vec<i32> = b[..8]
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]) as i32)
.collect();
return Some((v[1], v[0], v[3], v[2])); }
_ => {}
}
}
None
}
fn grid_table(cells: &[(Anchor, ShapeText)]) -> Option<Table> {
if cells.len() < 4 {
return None;
}
let tolerance = edge_tolerance(cells);
let col_edges = cluster(cells.iter().map(|((l, ..), _)| *l), tolerance);
let row_edges = cluster(cells.iter().map(|((_, t, ..), _)| *t), tolerance);
let (nrows, ncols) = (row_edges.len(), col_edges.len());
if nrows < 2 || ncols < 2 {
return None;
}
if cells.len() * 10 < nrows * ncols * 6 {
return None;
}
let index_of = |edges: &[i32], v: i32| -> usize {
edges
.iter()
.position(|&e| (v - e).abs() <= tolerance)
.unwrap_or_else(|| edges.iter().filter(|&&e| e < v).count().saturating_sub(1))
};
let mut grid: Vec<Vec<Option<String>>> = vec![vec![None; ncols]; nrows];
let mut col_cont = vec![vec![false; ncols]; nrows];
let mut row_cont = vec![vec![false; ncols]; nrows];
for ((l, t, r, b), text) in cells {
let ci = index_of(&col_edges, *l);
let ri = index_of(&row_edges, *t);
let col_span = 1 + col_edges[ci + 1..]
.iter()
.take_while(|&&e| e < *r - tolerance)
.count();
let row_span = 1 + row_edges[ri + 1..]
.iter()
.take_while(|&&e| e < *b - tolerance)
.count();
let value = text.text.replace('\r', "\n").trim().to_string();
for rr in ri..(ri + row_span).min(nrows) {
for cc in ci..(ci + col_span).min(ncols) {
let cell = grid.get_mut(rr).and_then(|row| row.get_mut(cc))?;
if cell.is_none() {
*cell = Some(value.clone());
}
if (rr, cc) == (ri, ci) {
continue;
}
if cc > ci {
col_cont[rr][cc] = true;
}
if rr > ri && cc == ci {
row_cont[rr][cc] = true;
}
if rr > ri && cc > ci {
row_cont[rr][cc] = true;
}
}
}
}
let rows: Vec<Vec<String>> = grid
.into_iter()
.map(|row| row.into_iter().map(Option::unwrap_or_default).collect())
.collect();
let any_span = col_cont
.iter()
.flatten()
.chain(row_cont.iter().flatten())
.any(|&x| x);
let structure = any_span.then(|| {
let mut header_row = vec![false; nrows];
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,
location: None,
structure,
cell_blocks: None,
cells: None,
caption: None,
caption_parent: Default::default(),
caption_location: None,
})
}
fn edge_tolerance(cells: &[(Anchor, ShapeText)]) -> i32 {
let avg_w: i32 = cells
.iter()
.map(|((l, _, r, _), _)| (r - l).abs())
.sum::<i32>()
/ cells.len().max(1) as i32;
(avg_w / 8).max(2)
}
fn cluster(values: impl Iterator<Item = i32>, tolerance: i32) -> Vec<i32> {
let mut v: Vec<i32> = values.collect();
v.sort_unstable();
let mut out: Vec<i32> = Vec::new();
for x in v {
match out.last() {
Some(&last) if (x - last).abs() <= tolerance => {}
_ => out.push(x),
}
}
out
}
fn has_record(body: &[u8], rec_type: u16) -> bool {
Records::new(body).any(|(h, _)| h.rec_type == rec_type)
}
fn find_container(body: &[u8], rec_type: u16, depth: usize) -> Option<&[u8]> {
if depth > 16 {
return None;
}
for (h, b) in Records::new(body) {
if h.rec_type == rec_type {
return Some(b);
}
if h.version == 0xF {
if let Some(found) = find_container(b, rec_type, depth + 1) {
return Some(found);
}
}
}
None
}
fn assemble_slide(
mut blocks: Vec<TextBlock>,
shapes: Vec<ShapeItem>,
master: &MasterStyles,
) -> Vec<Node> {
let mut nodes = Vec::new();
for item in shapes {
match item {
ShapeItem::Table { table } => nodes.push(Node::Table(table)),
ShapeItem::Text { text, .. } => {
let (is_title, text_type, content, styles, autonums) = match text.outline_ref {
Some(ix) => match blocks.get_mut(ix as usize) {
Some(block) => {
block.consumed = true;
let autos: Vec<_> = text.styles.iter().map(|s| s.autonum).collect();
(
block.is_title,
block.text_type,
block.text.clone(),
block.styles.clone(),
autos,
)
}
None => continue,
},
None => {
if let Some(block) = blocks
.iter_mut()
.find(|b| !b.consumed && b.text == text.text)
{
block.consumed = true;
}
let autos: Vec<_> = text.styles.iter().map(|s| s.autonum).collect();
(
text.is_title,
text.text_type,
text.text.clone(),
text.styles.clone(),
autos,
)
}
};
let para = Paragraphs {
is_title,
text: &content,
styles: &styles,
autonums: &autonums,
inherited: |indent| master.bullet(text_type, indent),
};
push_text(&mut nodes, para);
}
}
}
for block in blocks.iter().filter(|b| !b.consumed) {
let autos: Vec<_> = block.styles.iter().map(|s| s.autonum).collect();
let para = Paragraphs {
is_title: block.is_title,
text: &block.text,
styles: &block.styles,
autonums: &autos,
inherited: |indent| master.bullet(block.text_type, indent),
};
push_text(&mut nodes, para);
}
nodes
}
struct OpenList {
level: u8,
items: u64,
counter: u64,
first_enumerated: Option<bool>,
}
struct Paragraphs<'a, F: Fn(u8) -> bool> {
is_title: bool,
text: &'a str,
styles: &'a [ParaStyle],
autonums: &'a [Option<(u16, u16)>],
inherited: F,
}
fn push_text<F: Fn(u8) -> bool>(nodes: &mut Vec<Node>, para: Paragraphs<F>) {
let Paragraphs {
is_title,
text,
styles,
autonums,
inherited,
} = para;
let mut open_lists: Vec<OpenList> = Vec::new();
let mut run_ix = 0usize;
let mut run_left = styles.first().map(|s| s.count).unwrap_or(usize::MAX);
for line in text.split('\r') {
let chars = line.chars().count() + 1; let style = styles.get(run_ix).copied().unwrap_or_default();
let autonum = autonums.get(run_ix).copied().flatten().or(style.autonum);
if run_left <= chars {
run_ix += 1;
run_left = styles.get(run_ix).map(|s| s.count).unwrap_or(usize::MAX);
} else {
run_left -= chars;
}
if line.trim().is_empty() {
continue;
}
if is_title {
nodes.push(Node::Heading {
level: 1,
text: line.to_string(),
});
open_lists.clear();
continue;
}
let numbered = autonum.is_some();
let bullet = style.bullet.unwrap_or_else(|| inherited(style.indent));
if !bullet && !numbered {
nodes.push(Node::Paragraph {
text: line.to_string(),
});
open_lists.clear();
continue;
}
let level = style.indent;
while open_lists.len() > 1 && open_lists.last().is_some_and(|l| l.level > level) {
open_lists.pop();
}
let first_in_list = open_lists.is_empty();
if open_lists.is_empty() || open_lists.last().is_some_and(|l| level > l.level) {
open_lists.push(OpenList {
level,
items: 0,
counter: 0,
first_enumerated: None,
});
}
let depth = open_lists.len() - 1;
let current = open_lists.last_mut().expect("a list is open");
let n = if numbered {
if current.counter == 0 {
current.counter = autonum
.map_or(0, |(_, start)| start as u64)
.saturating_sub(1);
}
current.counter += 1;
current.counter
} else {
0
};
current.items += 1;
let first_enumerated = *current.first_enumerated.get_or_insert(numbered);
let (ordered, number) = match (numbered, first_enumerated) {
(false, true) => (true, current.items),
_ => (numbered, n),
};
nodes.push(Node::ListItem {
ordered,
number,
first_in_list,
text: line.to_string(),
level: depth as u8,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
});
}
}
fn utf16_text(b: &[u8]) -> String {
b.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.map(|u| char::from_u32(u as u32).unwrap_or('\u{FFFD}'))
.filter(|&c| c != '\u{0000}')
.collect()
}
fn bytes_text(b: &[u8]) -> String {
b.iter().map(|&x| super::doc::cp1252(x)).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_edit_chain_finds_the_encryption_session() {
let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/encrypted/");
let edits = |path: &str| {
let data = std::fs::read(path).unwrap();
let cfb = CompoundFile::open(&data).unwrap();
let doc = cfb.stream("PowerPoint Document").unwrap();
let user = cfb.stream("Current User").unwrap();
let edits = UserEdits::read(&user, &doc).expect("edit chain");
let session = edits.crypt_session(&doc).map(<[u8]>::len);
(edits.encrypted(), session)
};
for name in [
"min_encrypted.ppt",
"min_writepw.ppt",
"B_openpw.ppt",
"C_writepw.ppt",
"D_both.ppt",
"H_A_addpw_save.ppt",
] {
let (encrypted, session) = edits(&format!("{dir}{name}"));
assert!(encrypted, "{name}");
assert_eq!(session, Some(198), "{name}");
}
for plain in [
concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/data/ppt/sources/powerpoint_sample.ppt"
),
concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/data/ppt/sources/ppt_cfb_v4_edit_save.ppt"
),
] {
assert_eq!(edits(plain), (false, None), "{plain}");
}
}
use crate::InputFormat;
#[test]
fn grid_table_rejects_a_column_of_shapes() {
let cells: Vec<(Anchor, ShapeText)> = (0..4)
.map(|i| ((0, i * 100, 200, i * 100 + 90), ShapeText::default()))
.collect();
assert!(grid_table(&cells).is_none());
}
#[test]
fn grid_table_builds_2x2_with_span() {
let mk = |s: &str| ShapeText {
text: s.into(),
..ShapeText::default()
};
let cells = vec![
((0, 0, 200, 90), mk("header spans")),
((0, 100, 100, 190), mk("a")),
((100, 100, 200, 190), mk("b")),
((0, 200, 100, 290), mk("c")),
((100, 200, 200, 290), mk("d")),
];
let t = grid_table(&cells).expect("is a table");
assert_eq!(t.rows.len(), 3);
assert_eq!(
t.rows[0],
vec!["header spans".to_string(), "header spans".to_string()]
);
assert_eq!(t.rows[1], vec!["a".to_string(), "b".to_string()]);
let s = t.structure.expect("span structure");
assert!(s.col_continuation[0][1], "top row spans");
}
fn record(instance: u16, rec_type: u16, body: &[u8]) -> Vec<u8> {
let mut out = (instance << 4).to_le_bytes().to_vec();
out.extend(rec_type.to_le_bytes());
out.extend((body.len() as u32).to_le_bytes());
out.extend(body);
out
}
#[test]
fn master_text_styles_resolve_like_libreoffice() {
let pf = |bullet: Option<bool>| -> Vec<u8> {
match bullet {
Some(b) => [
1u32.to_le_bytes().to_vec(),
(b as u16).to_le_bytes().to_vec(),
]
.concat(),
None => 0u32.to_le_bytes().to_vec(),
}
};
let cf = [0x0004_0001u32.to_le_bytes().to_vec(), vec![0; 6]].concat();
let mut body_style = 3u16.to_le_bytes().to_vec();
for bullet in [Some(true), None, Some(false)] {
body_style.extend(pf(bullet));
body_style.extend(&cf);
}
let mut center = 1u16.to_le_bytes().to_vec();
center.extend(0u16.to_le_bytes());
center.extend(pf(Some(false)));
center.extend(&cf);
let mut other = 1u16.to_le_bytes().to_vec();
other.extend(pf(Some(false)));
other.extend(&cf);
let master = [
record(1, RT_TEXT_MASTER_STYLE_ATOM, &body_style),
record(5, RT_TEXT_MASTER_STYLE_ATOM, ¢er),
record(4, RT_TEXT_MASTER_STYLE_ATOM, &other),
]
.concat();
let styles = MasterStyles::read(&master);
assert!(styles.bullet(1, 0), "body level 1 bullets");
assert!(styles.bullet(1, 1), "unset level 2 takes level 1's");
assert!(!styles.bullet(1, 2), "level 3 says no bullet");
assert!(!styles.bullet(1, 4), "and level 5 inherits that");
assert!(!styles.bullet(5, 0), "the center body's own level 1");
assert!(styles.bullet(5, 1), "its unset level 2 is Body's");
assert!(styles.bullet(7, 0), "a half body with no style is Body");
assert!(!styles.bullet(4, 0), "other text");
assert!(!styles.bullet(0, 0), "no title style: no bullet");
}
#[test]
fn shapes_are_read_in_rows_then_left_to_right() {
let item = |s: &str| ShapeItem::Text {
anchor: None,
text: ShapeText {
text: s.into(),
..ShapeText::default()
},
};
let units = vec![
(None, vec![item("anchorless")]),
(Some((0, 500, 10, 600)), vec![item("title below")]),
(Some((50, 10, 60, 20)), vec![item("row right")]),
(
Some((0, 38, 10, 48)),
vec![item("row left, 28 units lower")],
),
(Some((0, 80, 10, 90)), vec![item("next row")]),
];
let order: Vec<String> = by_position(units)
.into_iter()
.map(|i| match i {
ShapeItem::Text { text, .. } => text.text,
ShapeItem::Table { .. } => unreachable!(),
})
.collect();
assert_eq!(
order,
[
"row left, 28 units lower",
"row right",
"next row",
"title below",
"anchorless"
]
);
}
#[test]
fn lists_nest_and_number_like_docling() {
let para = |count: usize, indent: u8, bullet: Option<bool>, autonum| ParaStyle {
count,
indent,
bullet,
autonum,
};
let styles = [
para(4, 0, Some(true), None),
para(4, 1, Some(false), None),
para(6, 2, Some(true), Some((3, 1))),
para(5, 2, Some(true), Some((3, 1))),
para(5, 0, Some(true), None),
];
let mut nodes = Vec::new();
push_text(
&mut nodes,
Paragraphs {
is_title: false,
text: "one\rtwo\rthree\rfour\rfive",
styles: &styles,
autonums: &[],
inherited: |_| false,
},
);
let shape: Vec<(bool, u64, bool, u8)> = nodes
.iter()
.map(|n| match n {
Node::ListItem {
ordered,
number,
first_in_list,
level,
..
} => (*ordered, *number, *first_in_list, *level),
Node::Paragraph { .. } => (false, 0, false, u8::MAX),
other => panic!("unexpected {other:?}"),
})
.collect();
assert_eq!(
shape,
vec![
(false, 0, true, 0),
(false, 0, false, u8::MAX),
(true, 1, true, 0), (true, 2, false, 0),
(true, 3, false, 0), ]
);
let styles = [
para(5, 0, Some(true), Some((3, 4))),
para(5, 0, Some(true), Some((3, 1))),
para(7, 0, Some(true), None),
para(7, 1, Some(true), None),
];
let mut nodes = Vec::new();
push_text(
&mut nodes,
Paragraphs {
is_title: false,
text: "four\rfive\rbullet\rnested",
styles: &styles,
autonums: &[],
inherited: |_| false,
},
);
let numbers: Vec<(bool, u64, u8)> = nodes
.iter()
.map(|n| match n {
Node::ListItem {
ordered,
number,
level,
..
} => (*ordered, *number, *level),
other => panic!("unexpected {other:?}"),
})
.collect();
assert_eq!(
numbers,
vec![(true, 4, 0), (true, 5, 0), (true, 3, 0), (false, 0, 1)]
);
}
#[test]
fn garbage_is_an_error_not_a_panic() {
let src = SourceDocument::from_bytes("x.ppt", InputFormat::Ppt, vec![0u8; 128]);
assert!(PptBackend.convert(&src).is_err());
}
}