mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
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>
159 lines
7.1 KiB
Rust
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)
|
|
}
|