Skip to main content

document_svg/document/
idml.rs

1//! Bounded Adobe InDesign Markup Language (IDML) package previews.
2//!
3//! IDML is an OPC-like ZIP package containing `designmap.xml`, stories,
4//! spreads and resources. This adapter inspects package and XML structure
5//! in memory only; fonts, images, scripts, links and external resources are
6//! never extracted, resolved or executed.
7
8use 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}