Files
designcraft/crates/idml/src/lib.rs
T
aionggandskylartaylor f70f93de44 fix(idml): report missing parts and show import warnings
Match IDML part names leniently, report missing parts, and show import warnings when opening a file. Cover the malformed URI crash with a regression test.

Validation: all six cargo xtask ci gates passed on the current main merge candidate; 1,176 native tests passed and all 15 WASM packages checked.
Hosted FreeBSD tests and Windows ARM64 packaging and install/run checks passed.

Task: #356

Co-authored-by: skylartaylor <26588250+skylartaylor@users.noreply.github.com>
2026-10-11 19:39:34 -06:00

159 lines
7.1 KiB
Rust

//! IDML (InDesign Markup Language) interchange: [`export_idml`] and [`import_idml`].
//!
//! Implemented clean-room from the public IDML File Format Specification and from inspecting
//! IDML packages exported from synthetic documents. An IDML package is a ZIP whose first entry is
//! an uncompressed `mimetype`, followed by `designmap.xml` (the root `Document` element that
//! includes the other parts) and the `Resources/`, `MasterSpreads/`, `Spreads/` and `Stories/`
//! parts.
//!
//! ## Coordinates
//! DesignCraft spreads have their origin at the top-left of the first page (y down). IDML spread
//! coordinates have their origin at the binding spine (facing pages) or the spread centre
//! (single-sided documents), vertically centred on the pages; pages are placed by their
//! `ItemTransform` and every page item's geometry is `PathGeometry` in inner coordinates mapped by
//! `ItemTransform` (inner → parent). Export maps `xf_idml = T(-origin) · xf`; import computes the
//! page rectangles from the page transforms and maps back.
//!
//! ## Supported subset
//! Swatches (process/spot CMYK, RGB and Lab colours, tints, linear/radial gradients), paragraph,
//! character and object styles (with `BasedOn` and style groups), fonts, document preferences
//! (page size, facing pages, bleed/slug, margins and columns, units, grids), layers, sections,
//! parent (master) spreads and applied parents, spreads and pages, page items (`TextFrame`,
//! `Rectangle`, `Oval`, `Polygon`, `GraphicLine`, `Group`) with fill/stroke, corner options, text
//! frame options (with column rules), text wrap, transparency (opacity, blend mode, drop shadow,
//! feather), images (embedded `Contents` or linked), stories with paragraph/character style ranges and local overrides, special
//! characters and breaks, and text threading.
//!
//! ## TODO (ignored on import / not written on export)
//! - Tables, footnotes, anchored/inline objects, notes, tracked changes, hyperlinks, index and
//! cross-reference markers, text variables, conditions, XML structure (`XML/*` parts).
//! - Guides, page-item overrides of parent items (only the page's `OverrideList` is read),
//! parent-of-parent page mapping beyond `AppliedMaster`, alternate layouts, liquid layout.
//! - Nested/GREP/line styles, bullets & numbering details (bullet glyph, number format), OpenType
//! feature flags, paragraph borders, grid alignment, custom dashed stroke styles; column rule
//! stroke type and overprint.
//! - Mixed inks, gradient stop opacity and gradient feathers, effects other than drop shadow and
//! basic feather, EPS/PDF/AI placed graphics (imported as images when the data is available),
//! clipping paths, compound-path fill rules.
//! - Unknown elements are ignored; nothing is preserved opaquely for round-trip yet.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod arabic;
mod cjk;
mod export;
mod import;
mod names;
mod xml;
#[cfg(test)]
mod tests;
pub use export::{ExportOptions, export_idml, export_idml_with};
pub use import::{Imported, import_idml, import_idml_report, import_idml_with};
/// The IDML package mimetype (content of the first, stored `mimetype` entry).
pub const MIMETYPE: &str = "application/vnd.adobe.indesign-idml-package";
/// The DOM version written to every part.
pub const DOM_VERSION: &str = "16.0";
#[derive(Debug, thiserror::Error)]
pub enum IdmlError {
#[error("not an IDML package: {0}")]
NotIdml(String),
#[error("IDML part {part}: {msg}")]
Part { part: String, msg: String },
#[error("invalid IDML document: {0}")]
Invalid(String),
}
pub type Result<T> = std::result::Result<T, IdmlError>;
/// Does `bytes` look like an IDML package (a ZIP whose first, stored entry is the IDML `mimetype`)?
pub fn is_idml(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK\x03\x04") && bytes.get(30..38) == Some(b"mimetype") && {
let extra = bytes.get(28..30).map_or(0, |e| u16::from_le_bytes([e[0], e[1]]) as usize);
bytes.get(38 + extra..).is_some_and(|rest| rest.starts_with(MIMETYPE.as_bytes()))
}
}
const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/// Base64 with line breaks every 76 characters (as embedded image `Contents` are written).
pub(crate) fn base64_encode(data: &[u8]) -> String {
let mut out = String::with_capacity(data.len().div_ceil(3) * 4 + data.len() / 57);
for (i, c) in data.chunks(3).enumerate() {
if i > 0 && i % 19 == 0 {
out.push('\n');
}
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
out.push(B64[(n >> 18) as usize & 63] as char);
out.push(B64[(n >> 12) as usize & 63] as char);
out.push(if c.len() > 1 { B64[(n >> 6) as usize & 63] as char } else { '=' });
out.push(if c.len() > 2 { B64[n as usize & 63] as char } else { '=' });
}
out
}
/// Lenient base64 decoding (whitespace and unknown characters are skipped).
pub(crate) fn base64_decode(s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len() * 3 / 4);
let mut buf = 0u32;
let mut bits = 0;
for b in s.bytes() {
let v = match b {
b'A'..=b'Z' => b - b'A',
b'a'..=b'z' => b - b'a' + 26,
b'0'..=b'9' => b - b'0' + 52,
b'+' | b'-' => 62,
b'/' | b'_' => 63,
_ => continue,
} as u32;
buf = buf << 6 | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
out
}
/// Sniff an image's MIME type and pixel size from its header (PNG, JPEG, GIF, WebP, TIFF).
pub(crate) fn sniff_image(b: &[u8]) -> (Option<&'static str>, Option<(u32, u32)>) {
let be32 = |i: usize| b.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]));
if b.starts_with(b"\x89PNG\r\n\x1a\n") {
return (Some("image/png"), be32(16).zip(be32(20)));
}
if b.starts_with(&[0xFF, 0xD8]) {
let mut i = 2;
while i + 9 < b.len() {
if b[i] != 0xFF {
i += 1;
continue;
}
let m = b[i + 1];
let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize;
if matches!(m, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF) {
let h = u16::from_be_bytes([b[i + 5], b[i + 6]]) as u32;
let w = u16::from_be_bytes([b[i + 7], b[i + 8]]) as u32;
return (Some("image/jpeg"), Some((w, h)));
}
i += 2 + len;
}
return (Some("image/jpeg"), None);
}
if b.starts_with(b"GIF8") && b.len() >= 10 {
return (Some("image/gif"), Some((u16::from_le_bytes([b[6], b[7]]) as u32, u16::from_le_bytes([b[8], b[9]]) as u32)));
}
if b.starts_with(b"RIFF") && b.get(8..12) == Some(b"WEBP") {
return (Some("image/webp"), None);
}
if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") {
return (Some("image/tiff"), None);
}
if b.starts_with(b"%PDF") {
return (Some("application/pdf"), None);
}
(None, None)
}