Skip to main content

document_svg/document/
iwork.rs

1//! Bounded Apple iWork package previews.
2//!
3//! Pages, Numbers, and Keynote documents are commonly distributed as package
4//! directories or ZIP-backed packages containing opaque IWA protobuf parts.
5//! This adapter handles the ZIP-backed form only.  It validates the archive
6//! through the shared bounded package reader and reports known marker entries;
7//! IWA protobuf payloads, previews, external links, and embedded media are
8//! never decoded or opened.
9
10use std::fs;
11use std::path::Path;
12
13use crate::convert::{ConvertOptions, PageConsumer};
14use crate::document::html::{HtmlBlock, render_blocks_to_pages};
15use crate::error::{Error, Result};
16use crate::ooxml::ZipPackage;
17use crate::table::{TableAlign, TableData};
18
19const MAX_IWORK_BYTES: u64 = 512 * 1024 * 1024;
20const MAX_IWORK_ENTRIES: usize = 100_000;
21
22#[derive(Clone, Copy)]
23enum Kind {
24    Pages,
25    Numbers,
26    Keynote,
27}
28
29impl Kind {
30    fn from_path(path: &Path) -> Option<Self> {
31        let extension = path.extension()?.to_str()?.to_ascii_lowercase();
32        match extension.as_str() {
33            "pages" => Some(Self::Pages),
34            "numbers" => Some(Self::Numbers),
35            "key" => Some(Self::Keynote),
36            _ => None,
37        }
38    }
39
40    fn label(self) -> &'static str {
41        match self {
42            Self::Pages => "Pages",
43            Self::Numbers => "Numbers",
44            Self::Keynote => "Keynote",
45        }
46    }
47}
48
49struct IworkPageSink<'a> {
50    inner: &'a mut dyn PageConsumer,
51    warnings: &'a [String],
52    title: &'static str,
53}
54
55impl PageConsumer for IworkPageSink<'_> {
56    fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
57        page.source_format = "iwork".into();
58        if page.title.is_empty() {
59            page.title = self.title.into();
60        }
61        page.description = "Apple iWork package structure is rendered as bounded inert metadata; IWA protobuf payloads and embedded resources are not decoded".into();
62        for warning in self.warnings {
63            page.warn(warning.clone());
64        }
65        self.inner.consume(page)
66    }
67}
68
69/// Recognize a ZIP-backed iWork package by known marker entries.  This avoids
70/// treating arbitrary ZIP files as Pages/Numbers/Keynote when the extension is
71/// missing.
72pub(crate) fn looks_like_archive(path: &Path) -> bool {
73    if fs::metadata(path)
74        .ok()
75        .is_none_or(|metadata| metadata.len() > MAX_IWORK_BYTES)
76    {
77        return false;
78    }
79    let Ok(mut package) = ZipPackage::open(path, 32 * 1024 * 1024) else {
80        return false;
81    };
82    package.entry_count() <= MAX_IWORK_ENTRIES
83        && [
84            "Index/Document.iwa",
85            "Index/Presentation.iwa",
86            "Index/Sheet.iwa",
87            "Metadata/Properties.plist",
88            "QuickLook/Preview.pdf",
89        ]
90        .into_iter()
91        .any(|name| package.contains(name))
92}
93
94pub(crate) fn convert(
95    path: &Path,
96    options: &ConvertOptions,
97    sink: &mut dyn PageConsumer,
98) -> Result<Vec<String>> {
99    let kind = Kind::from_path(path).ok_or_else(|| {
100        Error::InvalidInput("iWork package extension must be .pages, .numbers, or .key".into())
101    })?;
102    let metadata = fs::metadata(path)?;
103    let max_bytes = options.max_input_bytes.min(MAX_IWORK_BYTES);
104    if metadata.len() > max_bytes {
105        return Err(Error::LimitExceeded(format!(
106            "iWork input exceeds maximum bytes ({max_bytes})"
107        )));
108    }
109    let mut package = ZipPackage::open(path, options.max_zip_entry_bytes)?;
110    let entry_count = package.entry_count();
111    if entry_count > MAX_IWORK_ENTRIES {
112        return Err(Error::LimitExceeded(format!(
113            "iWork package entries exceed {MAX_IWORK_ENTRIES}"
114        )));
115    }
116    let marker_names = [
117        ("Index/Document.iwa", "document payload"),
118        ("Index/Presentation.iwa", "presentation payload"),
119        ("Index/Sheet.iwa", "spreadsheet payload"),
120        ("Metadata/Properties.plist", "metadata plist"),
121        ("QuickLook/Preview.pdf", "QuickLook preview"),
122        ("QuickLook/Thumbnail.png", "QuickLook thumbnail"),
123    ];
124    let mut rows = vec![
125        vec!["Application".into(), kind.label().into()],
126        vec!["Package bytes".into(), metadata.len().to_string()],
127        vec!["ZIP entries".into(), entry_count.to_string()],
128    ];
129    let mut marker_count = 0usize;
130    for (name, label) in marker_names {
131        if package.contains(name) {
132            marker_count += 1;
133            rows.push(vec![label.into(), "present".into()]);
134        }
135    }
136    rows.push(vec!["Known markers".into(), marker_count.to_string()]);
137    if marker_count == 0 {
138        return Err(Error::InvalidInput(
139            "iWork ZIP package does not contain a recognized Index/Metadata/QuickLook marker"
140                .into(),
141        ));
142    }
143    let title = match kind {
144        Kind::Pages => "Apple Pages package",
145        Kind::Numbers => "Apple Numbers package",
146        Kind::Keynote => "Apple Keynote package",
147    };
148    let blocks = vec![
149        HtmlBlock::Heading {
150            level: 1,
151            text: title.into(),
152        },
153        HtmlBlock::Paragraph {
154            text: "The ZIP-backed iWork package is valid. Opaque IWA records are summarized by bounded marker metadata without decoding document content or opening linked resources.".into(),
155        },
156        HtmlBlock::Table(TableData {
157            headers: vec!["Metric".into(), "Value".into()],
158            rows,
159            alignments: vec![TableAlign::Left, TableAlign::Right],
160            raw_source: String::new(),
161        }),
162    ];
163    let warnings = vec![
164        "IWA protobuf records, document text, formulas, slide geometry, styles, previews, and embedded media are not decoded".into(),
165        "External links, package file references, scripts, macros, and application operations remain inert and are never opened".into(),
166    ];
167    let mut page_sink = IworkPageSink {
168        inner: sink,
169        warnings: &warnings,
170        title,
171    };
172    render_blocks_to_pages(&blocks, &mut page_sink, options)?;
173    Ok(warnings)
174}