1use std::io::{Cursor, Read, Seek};
9use std::path::Path;
10
11use zip::ZipArchive;
12
13use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
14use crate::document::html::{HtmlBlock, render_blocks_to_pages};
15use crate::error::{Error, Result};
16use crate::geospatial::xml_tree::{XmlElement, XmlLimits, parse_xml_tree};
17use crate::table::{TableAlign, TableData};
18
19const MAX_IDML_BYTES: u64 = 256 * 1024 * 1024;
20const MAX_IDML_ENTRY_BYTES: u64 = 32 * 1024 * 1024;
21const MAX_IDML_EXPANDED_BYTES: u64 = 256 * 1024 * 1024;
22const MAX_IDML_ENTRIES: usize = 100_000;
23const MAX_IDML_XML_ENTRIES: usize = 20_000;
24const MAX_IDML_XML_EVENTS: usize = 500_000;
25const MAX_IDML_XML_NODES: usize = 300_000;
26const MAX_IDML_XML_DEPTH: usize = 128;
27const MAX_IDML_TEXT_BYTES: usize = 32 * 1024 * 1024;
28const MAX_IDML_ROWS: usize = 200_000;
29const MAX_IDML_DISPLAY_BYTES: usize = 512;
30
31#[derive(Default)]
32struct Summary {
33 entries: usize,
34 xml_parts: usize,
35 designmaps: usize,
36 stories: usize,
37 spreads: usize,
38 master_spreads: usize,
39 resources: usize,
40 images: usize,
41 fonts: usize,
42 stories_nodes: usize,
43 paragraphs: usize,
44 characters: usize,
45 hyperlinks: usize,
46 tables: usize,
47 graphics: usize,
48 swatches: usize,
49 rows: Vec<Vec<String>>,
50}
51
52struct IdmlPageSink<'a> {
53 inner: &'a mut dyn PageConsumer,
54 warnings: &'a [String],
55}
56
57impl PageConsumer for IdmlPageSink<'_> {
58 fn consume(&mut self, mut page: crate::ir::Page) -> Result<()> {
59 page.source_format = "idml".into();
60 if page.title.is_empty() {
61 page.title = "IDML InDesign package".into();
62 }
63 page.description =
64 "IDML package and XML structure is rendered as bounded inert metadata; layout payloads and external resources are not opened".into();
65 for warning in self.warnings {
66 page.warn(warning.clone());
67 }
68 self.inner.consume(page)
69 }
70}
71
72pub(crate) fn looks_like_archive(path: &Path) -> bool {
73 let Ok(metadata) = std::fs::metadata(path) else {
74 return false;
75 };
76 if metadata.len() > MAX_IDML_BYTES {
77 return false;
78 }
79 let Ok(bytes) = read_limited_file(path, MAX_IDML_BYTES, "IDML sniff") else {
80 return false;
81 };
82 let Ok(mut archive) = ZipArchive::new(Cursor::new(bytes)) else {
83 return false;
84 };
85 if archive.len() > MAX_IDML_ENTRIES {
86 return false;
87 }
88 (0..archive.len()).any(|index| {
89 archive
90 .by_index(index)
91 .ok()
92 .map(|entry| entry.name().eq_ignore_ascii_case("designmap.xml"))
93 .unwrap_or(false)
94 })
95}
96
97pub(crate) fn convert(
98 path: &Path,
99 options: &ConvertOptions,
100 sink: &mut dyn PageConsumer,
101) -> Result<Vec<String>> {
102 let bytes = read_limited_file(
103 path,
104 options.max_input_bytes.min(MAX_IDML_BYTES),
105 "IDML input",
106 )?;
107 let mut archive = ZipArchive::new(Cursor::new(bytes))
108 .map_err(|error| Error::InvalidInput(format!("invalid IDML ZIP package: {error}")))?;
109 if archive.len() > MAX_IDML_ENTRIES {
110 return Err(Error::LimitExceeded(format!(
111 "IDML entries exceed {MAX_IDML_ENTRIES}"
112 )));
113 }
114 let mut summary = Summary {
115 entries: archive.len(),
116 ..Summary::default()
117 };
118 let mut expanded_bytes = 0_u64;
119 let mut designmap_seen = false;
120 let mut xml_names = Vec::new();
121 for index in 0..archive.len() {
122 let entry = archive.by_index(index)?;
123 let name = entry.name().to_owned();
124 validate_entry_name(&name)?;
125 if name.eq_ignore_ascii_case("designmap.xml") {
126 designmap_seen = true;
127 summary.designmaps = summary.designmaps.saturating_add(1);
128 }
129 let lower = name.to_ascii_lowercase();
130 if lower.ends_with("/") {
131 continue;
132 }
133 if lower.starts_with("stories/") {
134 summary.stories = summary.stories.saturating_add(1);
135 }
136 if lower.starts_with("spreads/") {
137 summary.spreads = summary.spreads.saturating_add(1);
138 }
139 if lower.starts_with("masterspreads/") {
140 summary.master_spreads = summary.master_spreads.saturating_add(1);
141 }
142 if lower.starts_with("resources/") {
143 summary.resources = summary.resources.saturating_add(1);
144 if lower.contains("images/")
145 || lower.ends_with(".jpg")
146 || lower.ends_with(".png")
147 || lower.ends_with(".gif")
148 {
149 summary.images = summary.images.saturating_add(1);
150 }
151 if lower.contains("fonts/") || lower.ends_with(".otf") || lower.ends_with(".ttf") {
152 summary.fonts = summary.fonts.saturating_add(1);
153 }
154 }
155 if lower.ends_with(".xml") {
156 if xml_names.len() >= MAX_IDML_XML_ENTRIES {
157 return Err(Error::LimitExceeded(format!(
158 "IDML XML parts exceed {MAX_IDML_XML_ENTRIES}"
159 )));
160 }
161 xml_names.push(name);
162 }
163 }
164 if !designmap_seen {
165 return Err(Error::InvalidInput(
166 "IDML package has no designmap.xml".into(),
167 ));
168 }
169 for name in xml_names {
170 let data = read_entry(&mut archive, &name, &mut expanded_bytes)?;
171 summary.xml_parts = summary.xml_parts.saturating_add(1);
172 let root = parse_xml_tree(
173 &data,
174 &XmlLimits {
175 max_events: options.max_xml_events.min(MAX_IDML_XML_EVENTS),
176 max_nodes: MAX_IDML_XML_NODES,
177 max_depth: MAX_IDML_XML_DEPTH,
178 max_text_bytes: MAX_IDML_TEXT_BYTES,
179 },
180 "IDML XML",
181 )?;
182 summarize_xml(&root, &mut summary);
183 }
184 push_row(
185 &mut summary.rows,
186 "Package",
187 "IDML",
188 &format!("entries={} xmlParts={}", summary.entries, summary.xml_parts),
189 )?;
190 push_row(
191 &mut summary.rows,
192 "Layout",
193 &summary.spreads.to_string(),
194 &format!(
195 "stories={} masterSpreads={}",
196 summary.stories, summary.master_spreads
197 ),
198 )?;
199 push_row(
200 &mut summary.rows,
201 "Resources",
202 &summary.resources.to_string(),
203 &format!(
204 "images={} fonts={} swatches={}",
205 summary.images, summary.fonts, summary.swatches
206 ),
207 )?;
208 push_row(
209 &mut summary.rows,
210 "Text",
211 &summary.paragraphs.to_string(),
212 &format!(
213 "characters={} storyNodes={}",
214 summary.characters, summary.stories_nodes
215 ),
216 )?;
217 push_row(
218 &mut summary.rows,
219 "Links",
220 &summary.hyperlinks.to_string(),
221 &format!("tables={} graphics={}", summary.tables, summary.graphics),
222 )?;
223 let blocks = vec![
224 HtmlBlock::Heading {
225 level: 1,
226 text: "IDML InDesign package".into(),
227 },
228 HtmlBlock::Paragraph {
229 text: "InDesign Markup Language package structure is summarized without rendering or extracting private layout payloads.".into(),
230 },
231 HtmlBlock::Table(TableData {
232 headers: vec!["Kind".into(), "Value".into(), "Detail".into()],
233 rows: summary.rows,
234 alignments: vec![TableAlign::Left; 3],
235 raw_source: String::new(),
236 }),
237 ];
238 let warnings = vec![
239 "IDML IDs, text contents, links, image/font payloads, color values and private layout attributes are omitted or redacted; package structure only is shown".into(),
240 "IDML ZIP entries are read in memory under expanded-size limits; scripts, hyperlinks, URLs, fonts, images, external files and embedded resources are never executed or fetched".into(),
241 ];
242 let mut page_sink = IdmlPageSink {
243 inner: sink,
244 warnings: &warnings,
245 };
246 render_blocks_to_pages(&blocks, &mut page_sink, options)?;
247 Ok(warnings)
248}
249
250fn read_entry<R: Read + Seek>(
251 archive: &mut ZipArchive<R>,
252 name: &str,
253 expanded_bytes: &mut u64,
254) -> Result<Vec<u8>> {
255 let mut entry = archive.by_name(name)?;
256 let declared = entry.size();
257 if declared > MAX_IDML_ENTRY_BYTES {
258 return Err(Error::LimitExceeded(format!(
259 "IDML entry '{name}' exceeds {MAX_IDML_ENTRY_BYTES} bytes"
260 )));
261 }
262 let mut data = Vec::with_capacity(declared.min(MAX_IDML_ENTRY_BYTES) as usize);
263 entry
264 .by_ref()
265 .take(MAX_IDML_ENTRY_BYTES.saturating_add(1))
266 .read_to_end(&mut data)?;
267 if data.len() as u64 > MAX_IDML_ENTRY_BYTES {
268 return Err(Error::LimitExceeded(format!(
269 "IDML entry '{name}' expands beyond {MAX_IDML_ENTRY_BYTES} bytes"
270 )));
271 }
272 *expanded_bytes = expanded_bytes.saturating_add(data.len() as u64);
273 if *expanded_bytes > MAX_IDML_EXPANDED_BYTES {
274 return Err(Error::LimitExceeded(format!(
275 "IDML expanded XML exceeds {MAX_IDML_EXPANDED_BYTES} bytes"
276 )));
277 }
278 Ok(data)
279}
280
281fn validate_entry_name(name: &str) -> Result<()> {
282 let path = Path::new(name);
283 if path.is_absolute()
284 || name.starts_with('\\')
285 || name.split('/').any(|component| component == "..")
286 {
287 return Err(Error::InvalidInput(format!(
288 "IDML entry path escapes package root: {name}"
289 )));
290 }
291 Ok(())
292}
293
294fn summarize_xml(root: &XmlElement, summary: &mut Summary) {
295 summary.stories_nodes = summary
296 .stories_nodes
297 .saturating_add(usize::from(root.name.eq_ignore_ascii_case("Story")));
298 summary.paragraphs = summary
299 .paragraphs
300 .saturating_add(usize::from(root.name.eq_ignore_ascii_case("Paragraph")));
301 summary.characters = summary
302 .characters
303 .saturating_add(usize::from(root.name.eq_ignore_ascii_case("Character")));
304 summary.hyperlinks = summary
305 .hyperlinks
306 .saturating_add(usize::from(root.name.eq_ignore_ascii_case("Hyperlink")));
307 summary.tables = summary
308 .tables
309 .saturating_add(usize::from(root.name.eq_ignore_ascii_case("Table")));
310 summary.graphics = summary.graphics.saturating_add(
311 usize::from(root.name.eq_ignore_ascii_case("Rectangle"))
312 + usize::from(root.name.eq_ignore_ascii_case("Graphic"))
313 + usize::from(root.name.eq_ignore_ascii_case("Image")),
314 );
315 summary.swatches = summary
316 .swatches
317 .saturating_add(usize::from(root.name.eq_ignore_ascii_case("Color")));
318 for child in &root.children {
319 summarize_xml(child, summary);
320 }
321}
322
323fn push_row(rows: &mut Vec<Vec<String>>, kind: &str, value: &str, detail: &str) -> Result<()> {
324 if rows.len() >= MAX_IDML_ROWS {
325 return Err(Error::LimitExceeded(format!(
326 "IDML rows exceed {MAX_IDML_ROWS}"
327 )));
328 }
329 rows.push(vec![truncate(kind), truncate(value), truncate(detail)]);
330 Ok(())
331}
332
333fn truncate(value: &str) -> String {
334 if value.len() <= MAX_IDML_DISPLAY_BYTES {
335 value.to_owned()
336 } else {
337 let mut end = MAX_IDML_DISPLAY_BYTES;
338 while end > 0 && !value.is_char_boundary(end) {
339 end -= 1;
340 }
341 format!("{}…", &value[..end])
342 }
343}