document_svg/document/
iwork.rs1use 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
69pub(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}