use std::collections::HashMap;
use crate::backend::markdown::escape_text;
use crate::backend::ooxml::Package;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
use docling_core::{DoclingDocument, Node, Table, TableCell};
const TYPE_TEXT_STORAGE: u32 = 2001;
const TYPE_TP_DOCUMENT: u32 = 10000;
const TYPE_TSWP_PARAGRAPH_STYLE: u32 = 2022;
const TYPE_TST_TILE: u32 = 6002;
const SF_NS: &str = "http://developer.apple.com/namespaces/sf";
const SFA_NS: &str = "http://developer.apple.com/namespaces/sfa";
const MAX_LEGACY_XML_BYTES: u64 = 100 * 1024 * 1024;
const TYPE_TN_SHEET: u32 = 2;
const TYPE_TN_DOCUMENT: u32 = 1;
const TYPE_TST_TABLE_INFO: u32 = 6000;
const TYPE_TST_TABLE_MODEL: u32 = 6001;
const TYPE_TST_DATA_LIST: u32 = 6005;
const KIND_BODY: u64 = 0;
const KIND_HEADER: u64 = 1;
const KIND_FOOTNOTE: u64 = 2;
const KIND_NOTE: u64 = 4;
const KIND_CELL: u64 = 5;
struct Archive {
id: u64,
ty: u32,
payload: Vec<u8>,
}
pub struct IworkBackend;
impl DeclarativeBackend for IworkBackend {
fn convert(&self, source: &SourceDocument) -> Result<DoclingDocument, ConversionError> {
let mut pkg = Package::open(&source.bytes)
.ok_or_else(|| ConversionError::Parse("iwork: not a zip package".into()))?;
let flavor = Flavor::of(source.format);
if pkg.any_encrypted() {
return Err(ConversionError::Parse(
"iwork: the document is password-protected; docling.rs cannot read \
encrypted iWork documents. Remove the password in Pages and save again"
.into(),
));
}
if !pkg.names().any(|n| n.ends_with(".iwa")) {
let inner = pkg
.names()
.find(|n| *n == "Index.zip" || n.ends_with("/Index.zip"))
.map(str::to_string);
if let Some(inner) = inner {
if let Some(bytes) = pkg.read_bytes(&inner) {
pkg = Package::open(&bytes).ok_or_else(|| {
ConversionError::Parse("iwork: Index.zip is not a zip".into())
})?;
}
}
}
let has_iwa = pkg
.names()
.any(|n| n.ends_with(".iwa") && n.contains("Index/"));
let mut doc = DoclingDocument::new(&source.name);
if !has_iwa {
if flavor == Flavor::Pages {
let legacy = ["index.xml", "index.xml.gz"]
.into_iter()
.find(|m| pkg.names().any(|n| n == *m));
if let Some(member) = legacy {
convert_pages_legacy(&mut pkg, member, &mut doc)?;
return Ok(doc);
}
return Err(ConversionError::Parse(
"iwork: a ZIP archive, but not a Pages document — it has neither an \
Index/ directory nor an index.xml"
.into(),
));
}
return Err(ConversionError::Parse(
"iwork: no Index/*.iwa members — pre-2013 Numbers/Keynote documents \
(index.xml) are not supported"
.into(),
));
}
if flavor == Flavor::Pages {
convert_pages_iwa(&mut pkg, &mut doc)?;
return Ok(doc);
}
let mut names: Vec<String> = pkg
.names()
.filter(|n| n.ends_with(".iwa") && n.contains("Index/"))
.filter(|n| !n.contains("Index/MasterSlide"))
.map(str::to_string)
.collect();
names.sort_by_key(|n| (!n.ends_with("Index/Document.iwa"), natural_key(n)));
let mut archives: Vec<Archive> = Vec::new();
for name in &names {
let Some(bytes) = pkg.read_bytes(name) else {
continue;
};
if let Ok(stream) = decode_iwa(&bytes) {
parse_archives(&stream, &mut archives, false);
}
}
if docling_core::env::debug_enabled() {
let mut hist: HashMap<u32, usize> = HashMap::new();
for a in &archives {
*hist.entry(a.ty).or_default() += 1;
}
let mut counts: Vec<_> = hist.into_iter().collect();
counts.sort();
docling_core::debug_log!("iwork: archive types {counts:?}");
}
match flavor {
Flavor::Numbers => convert_numbers(&archives, &mut doc),
Flavor::Pages | Flavor::Keynote => convert_textual(flavor, &archives, &mut doc),
}
Ok(doc)
}
}
#[derive(Clone, Copy, PartialEq)]
enum Flavor {
Pages,
Numbers,
Keynote,
}
impl Flavor {
fn of(format: crate::InputFormat) -> Self {
match format {
crate::InputFormat::Numbers => Flavor::Numbers,
crate::InputFormat::Keynote => Flavor::Keynote,
_ => Flavor::Pages,
}
}
}
fn natural_key(name: &str) -> Vec<(u64, String)> {
let mut key = Vec::new();
let mut digits = String::new();
let mut text = String::new();
for c in name.chars() {
if c.is_ascii_digit() {
digits.push(c);
} else {
if !digits.is_empty() {
key.push((
digits.parse().unwrap_or(u64::MAX),
std::mem::take(&mut text),
));
digits.clear();
}
text.push(c);
}
}
key.push((digits.parse().unwrap_or(u64::MAX), text));
key
}
fn decode_iwa(bytes: &[u8]) -> Result<Vec<u8>, ConversionError> {
let mut out = Vec::with_capacity(bytes.len() * 3);
let mut pos = 0usize;
let mut snappy = snap::raw::Decoder::new();
while pos + 4 <= bytes.len() {
let len = u32::from_le_bytes([bytes[pos + 1], bytes[pos + 2], bytes[pos + 3], 0]) as usize;
let ty = bytes[pos];
pos += 4;
let Some(block) = bytes.get(pos..pos + len) else {
return Err(ConversionError::Parse("iwork: truncated IWA chunk".into()));
};
pos += len;
match ty {
0 => out.extend_from_slice(
&snappy
.decompress_vec(block)
.map_err(|e| ConversionError::Parse(format!("iwork: snappy: {e}")))?,
),
1 => out.extend_from_slice(block),
other => {
return Err(ConversionError::Parse(format!(
"iwork: unknown IWA chunk type {other}"
)))
}
}
}
Ok(out)
}
fn parse_archives(mut stream: &[u8], out: &mut Vec<Archive>, all_messages: bool) {
while !stream.is_empty() {
let Some((info_len, rest)) = read_varint(stream) else {
return;
};
let Some(info) = rest.get(..info_len as usize) else {
return;
};
let after = &rest[info_len as usize..];
let mut id = 0u64;
let mut messages: Vec<(u32, usize)> = Vec::new();
for (field, value) in Fields::new(info) {
match (field, value) {
(1, Value::Varint(v)) => id = v,
(2, Value::Bytes(mi)) => {
let (mut ty, mut len) = (0u32, 0usize);
for (f, v) in Fields::new(mi) {
match (f, v) {
(1, Value::Varint(t)) => ty = t as u32,
(3, Value::Varint(l)) => len = l as usize,
_ => {}
}
}
messages.push((ty, len));
}
_ => {}
}
}
let mut consumed = 0usize;
for (i, (ty, len)) in messages.iter().enumerate() {
let Some(payload) = after.get(consumed..consumed + len) else {
return;
};
if i == 0 || all_messages {
out.push(Archive {
id,
ty: *ty,
payload: payload.to_vec(),
});
}
consumed += len;
}
let Some(next) = after.get(consumed..) else {
return;
};
stream = next;
}
}
enum Value<'a> {
Varint(u64),
Bytes(&'a [u8]),
#[allow(dead_code)]
Fixed32(u32),
#[allow(dead_code)]
Fixed64(u64),
}
struct Fields<'a>(&'a [u8]);
impl<'a> Fields<'a> {
fn new(buf: &'a [u8]) -> Self {
Fields(buf)
}
}
impl<'a> Iterator for Fields<'a> {
type Item = (u32, Value<'a>);
fn next(&mut self) -> Option<Self::Item> {
let (tag, rest) = read_varint(self.0)?;
let field = (tag >> 3) as u32;
let value = match tag & 7 {
0 => {
let (v, rest) = read_varint(rest)?;
self.0 = rest;
Value::Varint(v)
}
1 => {
let v = u64::from_le_bytes(rest.get(..8)?.try_into().ok()?);
self.0 = &rest[8..];
Value::Fixed64(v)
}
2 => {
let (len, rest) = read_varint(rest)?;
let bytes = rest.get(..len as usize)?;
self.0 = &rest[len as usize..];
Value::Bytes(bytes)
}
5 => {
let v = u32::from_le_bytes(rest.get(..4)?.try_into().ok()?);
self.0 = &rest[4..];
Value::Fixed32(v)
}
_ => return None,
};
Some((field, value))
}
}
fn read_varint(buf: &[u8]) -> Option<(u64, &[u8])> {
let mut value = 0u64;
for (i, &b) in buf.iter().enumerate().take(10) {
value |= u64::from(b & 0x7f) << (7 * i as u32);
if b & 0x80 == 0 {
return Some((value, &buf[i + 1..]));
}
}
None
}
fn reference(bytes: &[u8]) -> Option<u64> {
Fields::new(bytes).find_map(|(f, v)| match (f, v) {
(1, Value::Varint(id)) => Some(id),
_ => None,
})
}
fn storage_text(payload: &[u8]) -> (u64, Vec<String>) {
let mut kind = 3; let mut texts = Vec::new();
for (f, v) in Fields::new(payload) {
match (f, v) {
(1, Value::Varint(k)) => kind = k,
(3, Value::Bytes(b)) => {
if let Ok(s) = std::str::from_utf8(b) {
texts.push(s.to_string());
}
}
_ => {}
}
}
(kind, texts)
}
fn paragraphs(texts: &[String]) -> Vec<String> {
let mut out = Vec::new();
for block in texts {
for line in block.split(['\n', '\u{2029}']) {
let cleaned: String = line
.chars()
.filter(|c| !matches!(c, '\u{FFFC}' | '\u{FFFB}' | '\u{E000}'..='\u{F8FF}'))
.collect();
let trimmed = cleaned.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
}
}
out
}
fn convert_textual(flavor: Flavor, archives: &[Archive], doc: &mut DoclingDocument) {
let mut boxes: Vec<String> = Vec::new();
for a in archives {
if a.ty != TYPE_TEXT_STORAGE {
continue;
}
let (kind, texts) = storage_text(&a.payload);
match kind {
KIND_CELL | KIND_NOTE | KIND_HEADER | KIND_FOOTNOTE => continue,
KIND_BODY if flavor == Flavor::Pages => {
for p in paragraphs(&texts) {
doc.push(Node::Paragraph { text: p });
}
}
_ => boxes.extend(paragraphs(&texts)),
}
}
let mut seen = std::collections::HashSet::new();
for p in boxes {
if seen.insert(p.clone()) {
doc.push(Node::Paragraph { text: p });
}
}
let by_id: HashMap<u64, &Archive> = archives.iter().map(|a| (a.id, a)).collect();
for model in archives.iter().filter(|a| a.ty == TYPE_TST_TABLE_MODEL) {
emit_table(model, &by_id, doc);
}
}
fn convert_numbers(archives: &[Archive], doc: &mut DoclingDocument) {
let by_id: HashMap<u64, &Archive> = archives.iter().map(|a| (a.id, a)).collect();
let sheets: Vec<u64> = archives
.iter()
.filter(|a| a.ty == TYPE_TN_DOCUMENT)
.flat_map(|a| {
Fields::new(&a.payload)
.filter_map(|(f, v)| match (f, v) {
(1, Value::Bytes(b)) => reference(b),
_ => None,
})
.collect::<Vec<_>>()
})
.collect();
for sheet_id in sheets {
let Some(sheet) = by_id.get(&sheet_id).filter(|a| a.ty == TYPE_TN_SHEET) else {
continue;
};
let mut name = String::new();
let mut drawables = Vec::new();
for (f, v) in Fields::new(&sheet.payload) {
match (f, v) {
(1, Value::Bytes(b)) => name = String::from_utf8_lossy(b).into_owned(),
(2, Value::Bytes(b)) => drawables.extend(reference(b)),
_ => {}
}
}
if !name.trim().is_empty() {
doc.push(Node::Heading {
level: 1,
text: name.trim().to_string(),
});
}
for id in drawables {
let Some(info) = by_id.get(&id).filter(|a| a.ty == TYPE_TST_TABLE_INFO) else {
continue;
};
let model = Fields::new(&info.payload).find_map(|(f, v)| match (f, v) {
(2, Value::Bytes(b)) => reference(b),
_ => None,
});
let Some(model) = model.and_then(|id| by_id.get(&id)) else {
continue;
};
if model.ty != TYPE_TST_TABLE_MODEL {
continue;
}
emit_table(model, &by_id, doc);
}
}
}
fn emit_table(model: &Archive, by_id: &HashMap<u64, &Archive>, doc: &mut DoclingDocument) {
let mut table_name = String::new();
let mut string_table = None;
let mut rich_table = None;
for (f, v) in Fields::new(&model.payload) {
match (f, v) {
(8, Value::Bytes(b)) => table_name = String::from_utf8_lossy(b).into_owned(),
(4, Value::Bytes(store)) => {
for (sf, sv) in Fields::new(store) {
match (sf, sv) {
(4, Value::Bytes(b)) => string_table = reference(b),
(17, Value::Bytes(b)) => rich_table = reference(b),
_ => {}
}
}
}
_ => {}
}
}
if !table_name.trim().is_empty() {
doc.push(Node::Heading {
level: 2,
text: table_name.trim().to_string(),
});
}
let mut entries: Vec<(u64, String)> = Vec::new();
if let Some(list) = string_table.and_then(|id| by_id.get(&id)) {
collect_list_entries(list, by_id, &mut entries);
}
if let Some(list) = rich_table.and_then(|id| by_id.get(&id)) {
collect_list_entries(list, by_id, &mut entries);
}
entries.sort_by_key(|(k, _)| *k);
for (i, (_, text)) in entries.into_iter().enumerate() {
doc.push(Node::ListItem {
ordered: false,
number: 0,
first_in_list: i == 0,
text,
level: 0,
marker: None,
location: None,
dclx: None,
href: None,
layer: None,
});
}
}
fn collect_list_entries(
list: &Archive,
by_id: &HashMap<u64, &Archive>,
out: &mut Vec<(u64, String)>,
) {
if list.ty != TYPE_TST_DATA_LIST {
return;
}
for (f, v) in Fields::new(&list.payload) {
if let (3, Value::Bytes(entry)) = (f, v) {
let (mut key, mut s, mut rich) = (0u64, None, None);
for (ef, ev) in Fields::new(entry) {
match (ef, ev) {
(1, Value::Varint(k)) => key = k,
(3, Value::Bytes(b)) => s = std::str::from_utf8(b).ok().map(str::to_string),
(9, Value::Bytes(b)) => rich = reference(b),
_ => {}
}
}
if s.is_none() {
if let Some(storage) = rich.and_then(|id| by_id.get(&id)) {
if storage.ty == TYPE_TEXT_STORAGE {
let (_, texts) = storage_text(&storage.payload);
let joined = paragraphs(&texts).join(" ");
if !joined.is_empty() {
s = Some(joined);
}
}
}
}
if let Some(s) = s {
let t = s.trim();
if !t.is_empty() {
out.push((key, t.to_string()));
}
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PagesLabel {
Text,
Title,
Heading(u8),
}
fn label_for_style(style_name: Option<&str>) -> PagesLabel {
let Some(name) = style_name else {
return PagesLabel::Text;
};
let name = name.trim();
if name.is_empty() {
return PagesLabel::Text;
}
let lowered = name.to_lowercase();
if lowered == "title" {
return PagesLabel::Title;
}
if lowered == "subtitle" || lowered == "subheading" {
return PagesLabel::Heading(2);
}
if let Some(rest) = lowered.strip_prefix("heading") {
let digits = rest.trim_start();
if digits.is_empty() {
return PagesLabel::Heading(1);
}
if digits.chars().all(|c| c.is_ascii_digit()) {
let level = digits.parse::<u64>().unwrap_or(u64::MAX).min(6) as u8;
return PagesLabel::Heading(level);
}
}
PagesLabel::Text
}
fn clean_pages_text(text: &str) -> String {
text.replace('\u{FFFC}', "").trim().to_string()
}
fn push_pages_paragraphs(paragraphs: Vec<(String, PagesLabel)>, doc: &mut DoclingDocument) {
for (text, label) in paragraphs {
let text = escape_text(&text);
match label {
PagesLabel::Text => doc.push(Node::Paragraph { text }),
PagesLabel::Title => doc.push(Node::Heading { level: 1, text }),
PagesLabel::Heading(level) => doc.push(Node::Heading {
level: level.saturating_add(1),
text,
}),
}
}
}
fn first_bytes(payload: &[u8], field: u32) -> Option<&[u8]> {
Fields::new(payload).find_map(|(f, v)| match v {
Value::Bytes(b) if f == field => Some(b),
_ => None,
})
}
fn first_varint(payload: &[u8], field: u32) -> Option<u64> {
Fields::new(payload).find_map(|(f, v)| match v {
Value::Varint(n) if f == field => Some(n),
_ => None,
})
}
fn convert_pages_iwa(pkg: &mut Package, doc: &mut DoclingDocument) -> Result<(), ConversionError> {
let names: Vec<String> = pkg
.names()
.filter(|n| n.ends_with(".iwa"))
.map(str::to_string)
.collect();
let mut archives: Vec<Archive> = Vec::new();
for name in &names {
let Some(bytes) = pkg.read_bytes(name) else {
continue;
};
let stream = decode_iwa(&bytes)?;
parse_archives(&stream, &mut archives, true);
}
let mut order: Vec<u64> = Vec::new();
let mut by_id: HashMap<u64, &Archive> = HashMap::new();
for a in &archives {
if by_id.insert(a.id, a).is_none() {
order.push(a.id);
}
}
let document = order
.iter()
.filter_map(|id| by_id.get(id))
.find(|a| a.ty == TYPE_TP_DOCUMENT)
.ok_or_else(|| {
ConversionError::Parse(
"iwork: the Pages document has no TP.DocumentArchive; the container may \
be corrupt or password-protected"
.into(),
)
})?;
let storage = first_bytes(&document.payload, 4)
.and_then(reference)
.and_then(|id| by_id.get(&id))
.filter(|a| a.ty == TYPE_TEXT_STORAGE)
.ok_or_else(|| {
ConversionError::Parse(
"iwork: the Pages document does not reference a body text storage".into(),
)
})?;
let mut text = String::new();
for (f, v) in Fields::new(&storage.payload) {
if let (3, Value::Bytes(b)) = (f, v) {
text.push_str(&String::from_utf8_lossy(b));
}
}
let runs = iwa_style_runs(&storage.payload, &by_id);
push_pages_paragraphs(split_pages_paragraphs(&text, &runs), doc);
for id in &order {
let Some(model) = by_id.get(id) else {
continue;
};
if model.ty == TYPE_TST_TABLE_MODEL {
if let Some(table) = iwa_table(model, &by_id) {
doc.push(Node::Table(table));
}
}
}
Ok(())
}
fn iwa_style_runs(
storage_payload: &[u8],
by_id: &HashMap<u64, &Archive>,
) -> Vec<(usize, Option<String>)> {
let Some(table) = first_bytes(storage_payload, 5) else {
return Vec::new();
};
let mut runs: Vec<(usize, Option<String>)> = Vec::new();
for (f, v) in Fields::new(table) {
let (1, Value::Bytes(entry)) = (f, v) else {
continue;
};
let (Some(index), Some(target)) = (
first_varint(entry, 1),
first_bytes(entry, 2).and_then(reference),
) else {
continue;
};
let Some(style) = by_id
.get(&target)
.filter(|a| a.ty == TYPE_TSWP_PARAGRAPH_STYLE)
else {
continue;
};
runs.push((index as usize, iwa_style_name(&style.payload)));
}
runs.sort_by_key(|run| run.0);
runs
}
fn iwa_style_name(payload: &[u8]) -> Option<String> {
let super_message = first_bytes(payload, 1)?;
let name = first_bytes(super_message, 1)?;
std::str::from_utf8(name).ok().map(str::to_string)
}
fn split_pages_paragraphs(
text: &str,
style_runs: &[(usize, Option<String>)],
) -> Vec<(String, PagesLabel)> {
let mut out = Vec::new();
let mut offset = 0usize;
let mut run_index = 0usize;
let mut current: Option<&str> = None;
for line in text.split('\n') {
while run_index < style_runs.len() && style_runs[run_index].0 <= offset {
current = style_runs[run_index].1.as_deref();
run_index += 1;
}
let cleaned = clean_pages_text(line);
if !cleaned.is_empty() {
out.push((cleaned, label_for_style(current)));
}
offset += line.chars().count() + 1;
}
out
}
fn iwa_table(model: &Archive, by_id: &HashMap<u64, &Archive>) -> Option<Table> {
let num_rows = first_varint(&model.payload, 6)? as usize;
let num_cols = first_varint(&model.payload, 7)? as usize;
let store = first_bytes(&model.payload, 4)?;
if num_rows == 0 || num_cols == 0 {
return None;
}
let header_rows = first_varint(&model.payload, 9).unwrap_or(0) as usize;
let strings = iwa_string_table(store, by_id);
let mut cells: Vec<TableCell> = Vec::new();
for tile in iwa_tiles(store, by_id) {
iwa_tile_cells(tile, &strings, num_cols, header_rows, &mut cells);
}
if cells.is_empty() {
return None;
}
Some(grid_table(num_rows, num_cols, cells))
}
fn grid_table(num_rows: usize, num_cols: usize, cells: Vec<TableCell>) -> Table {
let mut rows = vec![vec![String::new(); num_cols]; num_rows];
for cell in &cells {
if cell.start_row < num_rows && cell.start_col < num_cols {
rows[cell.start_row][cell.start_col] = cell.text.clone();
}
}
Table {
rows,
cells: Some(cells),
..Default::default()
}
}
fn text_cell(text: String, row: usize, col: usize, header_rows: usize) -> TableCell {
TableCell {
text,
bbox: None,
start_row: row,
start_col: col,
row_span: 1,
col_span: 1,
column_header: row < header_rows,
row_header: false,
row_section: false,
}
}
fn iwa_string_table(store: &[u8], by_id: &HashMap<u64, &Archive>) -> HashMap<u64, String> {
let mut strings = HashMap::new();
let Some(list) = first_bytes(store, 4)
.and_then(reference)
.and_then(|id| by_id.get(&id))
.filter(|a| a.ty == TYPE_TST_DATA_LIST)
else {
return strings;
};
for (f, v) in Fields::new(&list.payload) {
let (3, Value::Bytes(entry)) = (f, v) else {
continue;
};
if let (Some(key), Some(value)) = (first_varint(entry, 1), first_bytes(entry, 3)) {
strings.insert(key, String::from_utf8_lossy(value).into_owned());
}
}
strings
}
fn iwa_tiles<'a>(store: &[u8], by_id: &HashMap<u64, &'a Archive>) -> Vec<&'a Archive> {
let Some(container) = first_bytes(store, 3) else {
return Vec::new();
};
Fields::new(container)
.filter_map(|(f, v)| match (f, v) {
(1, Value::Bytes(entry)) => first_bytes(entry, 2)
.and_then(reference)
.and_then(|id| by_id.get(&id).copied())
.filter(|a| a.ty == TYPE_TST_TILE),
_ => None,
})
.collect()
}
fn iwa_tile_cells(
tile: &Archive,
strings: &HashMap<u64, String>,
num_cols: usize,
header_rows: usize,
out: &mut Vec<TableCell>,
) {
for (f, v) in Fields::new(&tile.payload) {
let (5, Value::Bytes(row)) = (f, v) else {
continue;
};
let (Some(row_index), Some(storage), Some(offsets)) = (
first_varint(row, 1),
first_bytes(row, 3),
first_bytes(row, 4),
) else {
continue;
};
let row_index = row_index as usize;
for column in 0..num_cols.min(offsets.len() / 2) {
let start = i16::from_le_bytes([offsets[2 * column], offsets[2 * column + 1]]);
let Some(text) = iwa_cell_text(storage, start, strings) else {
continue;
};
out.push(text_cell(text, row_index, column, header_rows));
}
}
}
fn iwa_cell_text(storage: &[u8], start: i16, strings: &HashMap<u64, String>) -> Option<String> {
if start < 0 {
return None;
}
let start = start as usize;
let key_at = start.checked_add(16)?;
let key_bytes = storage.get(key_at..key_at + 4)?;
if storage[start] != 4 || storage[start + 1] != 3 {
return None;
}
let key = u64::from(u32::from_le_bytes(key_bytes.try_into().ok()?));
strings.get(&key).cloned()
}
fn convert_pages_legacy(
pkg: &mut Package,
member: &str,
doc: &mut DoclingDocument,
) -> Result<(), ConversionError> {
let raw = pkg.read_bytes(member).ok_or_else(|| {
ConversionError::Parse(format!(
"iwork: could not read '{member}' from the Pages document"
))
})?;
let raw = if member.ends_with(".gz") {
gunzip_capped(&raw, MAX_LEGACY_XML_BYTES, member)?
} else {
raw
};
let xml = String::from_utf8(raw)
.map_err(|_| ConversionError::Parse(format!("iwork: '{member}' is not UTF-8")))?;
let dom = roxmltree::Document::parse(&xml)
.map_err(|e| ConversionError::Parse(format!("iwork: could not parse '{member}': {e}")))?;
push_pages_paragraphs(legacy_paragraphs(&dom), doc);
for table in legacy_tables(&dom) {
doc.push(Node::Table(table));
}
Ok(())
}
fn gunzip_capped(raw: &[u8], cap: u64, member: &str) -> Result<Vec<u8>, ConversionError> {
use std::io::Read;
let mut out = Vec::new();
flate2::read::GzDecoder::new(raw)
.take(cap + 1)
.read_to_end(&mut out)
.map_err(|_| ConversionError::Parse(format!("iwork: could not decompress '{member}'")))?;
if out.len() as u64 > cap {
return Err(ConversionError::Parse(format!(
"iwork: '{member}' expands beyond the {cap} byte limit"
)));
}
Ok(out)
}
fn is_sf(node: roxmltree::Node, name: &str) -> bool {
node.is_element()
&& node.tag_name().namespace() == Some(SF_NS)
&& node.tag_name().name() == name
}
fn legacy_paragraphs(dom: &roxmltree::Document) -> Vec<(String, PagesLabel)> {
let mut style_names: HashMap<&str, Option<&str>> = HashMap::new();
for style in dom.descendants().filter(|n| is_sf(*n, "paragraphstyle")) {
if let Some(ident) = style.attribute((SF_NS, "ident")) {
style_names.insert(ident, style.attribute((SF_NS, "name")));
}
}
let mut paragraphs = Vec::new();
let mut stack = vec![dom.root_element()];
while let Some(node) = stack.pop() {
if is_sf(node, "p") {
paragraphs.push(node);
}
for child in node.children().rev() {
if child.is_element()
&& !(is_sf(child, "header") || is_sf(child, "footer") || is_sf(child, "footnotes"))
{
stack.push(child);
}
}
}
let mut out = Vec::new();
for para in paragraphs {
let mut text = String::new();
for node in para.descendants().filter(|n| n.is_text()) {
let in_ghost = node
.ancestors()
.take_while(|a| *a != para)
.any(|a| is_sf(a, "ghost-text"));
if !in_ghost {
text.push_str(node.text().unwrap_or(""));
}
}
let cleaned = clean_pages_text(&text);
if cleaned.is_empty() {
continue;
}
let style = para
.attribute((SF_NS, "style"))
.and_then(|ident| style_names.get(ident).copied())
.flatten();
out.push((cleaned, label_for_style(style)));
}
out
}
fn int_attr(node: roxmltree::Node, name: &str) -> Option<usize> {
node.attribute((SF_NS, name))?.trim().parse().ok()
}
fn legacy_tables(dom: &roxmltree::Document) -> Vec<Table> {
let mut tables = Vec::new();
for model in dom.descendants().filter(|n| is_sf(*n, "tabular-model")) {
let Some(grid) = model.descendants().find(|n| is_sf(*n, "grid")) else {
continue;
};
let (Some(num_cols), Some(num_rows)) =
(int_attr(grid, "numcols"), int_attr(grid, "numrows"))
else {
continue;
};
if num_cols == 0 || num_rows == 0 {
continue;
}
let header_rows = int_attr(model, "num-header-rows").unwrap_or(0);
let values: Vec<String> = model
.descendants()
.filter(|n| is_sf(*n, "ct"))
.map(|cell| {
let text = match cell.attribute((SFA_NS, "s")) {
Some(s) if !s.is_empty() => s.to_string(),
_ => cell
.descendants()
.filter_map(|n| n.text())
.collect::<String>(),
};
clean_pages_text(&text)
})
.collect();
if values.is_empty() {
continue;
}
let cells = values
.into_iter()
.take(num_cols * num_rows)
.enumerate()
.map(|(index, text)| text_cell(text, index / num_cols, index % num_cols, header_rows))
.collect();
tables.push(grid_table(num_rows, num_cols, cells));
}
tables
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn varints_and_fields_walk_defensively() {
assert_eq!(read_varint(&[0x96, 0x01]), Some((150, &[][..])));
assert_eq!(read_varint(&[0x80]), None); let msg = [0x08, 0x05, 0x12, 0x02, b'a', b'b'];
let items: Vec<u32> = Fields::new(&msg).map(|(f, _)| f).collect();
assert_eq!(items, vec![1, 2]);
let bad = [0x12, 0x0A, b'x'];
assert_eq!(Fields::new(&bad).count(), 0);
}
#[test]
fn style_names_map_to_labels_like_docling() {
use PagesLabel::*;
for (name, want) in [
(Some("Title"), Title),
(Some("Heading 1"), Heading(1)),
(Some("Heading 2"), Heading(2)),
(Some("Heading"), Heading(1)),
(Some("heading 9"), Heading(6)),
(Some("Subheading"), Heading(2)),
(Some("Subtitle"), Heading(2)),
(Some("Body"), Text),
(Some("Free Form"), Text),
(Some("Footnote Text"), Text),
(Some("Heading one"), Text),
(None, Text),
] {
assert_eq!(label_for_style(name), want, "{name:?}");
}
}
#[test]
fn paragraph_split_follows_style_runs() {
let runs = vec![
(0, Some("Title".to_string())),
(6, Some("Body".to_string())),
];
let paras = split_pages_paragraphs("Titl\u{FFFC}e\n\nBödy one\nBody two", &runs);
assert_eq!(
paras,
vec![
("Title".to_string(), PagesLabel::Title),
("Bödy one".to_string(), PagesLabel::Text),
("Body two".to_string(), PagesLabel::Text),
]
);
}
#[test]
fn natural_order_sorts_slides_numerically() {
let mut names = vec!["Index/Slide10.iwa", "Index/Slide2.iwa", "Index/Slide1.iwa"];
names.sort_by_key(|n| natural_key(n));
assert_eq!(
names,
vec!["Index/Slide1.iwa", "Index/Slide2.iwa", "Index/Slide10.iwa"]
);
}
}