use std::collections::HashMap;
use crate::backend::ooxml::Package;
use crate::backend::DeclarativeBackend;
use crate::error::ConversionError;
use crate::source::SourceDocument;
use docling_core::{DoclingDocument, Node};
const TYPE_TEXT_STORAGE: u32 = 2001;
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()))?;
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 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();
if names.is_empty() {
return Err(ConversionError::Parse(
"iwork: no Index/*.iwa members — pre-2013 iWork documents (index.xml) \
are not supported"
.into(),
));
}
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);
}
}
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:?}");
}
let mut doc = DoclingDocument::new(&source.name);
let flavor = Flavor::of(source.format);
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>) {
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 first = true;
let mut first_ty = 0u32;
let mut first_len = 0usize;
let mut total_len = 0usize;
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,
_ => {}
}
}
if first {
first_ty = ty;
first_len = len;
first = false;
}
total_len += len;
}
_ => {}
}
}
let Some(payload) = after.get(..first_len) else {
return;
};
out.push(Archive {
id,
ty: first_ty,
payload: payload.to_vec(),
});
let Some(next) = after.get(total_len..) 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()));
}
}
}
}
}
#[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 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"]
);
}
}