mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
* IDML: keep column guides that were dragged off centre (#1096)
InDesign records dragged column guides in `MarginPreference ColumnsPositions`,
as pairs of offsets from the margin box's left edge. The importer discarded them
and divided the columns evenly, so a page whose gutter the designer had moved
came back with it in the middle: on a 450 pt margin box, `0 150 170 450` was
read as columns at 215 and 235.
`Columns::positions` already existed, documented as "Custom column positions
(after dragging column guides)", but nothing set it and nothing read it. Now
`Page::column_rects` returns the custom rects when they are set — that one
method is what the canvas guides, snapping, threading and the first frame of a
placed story all go through, so they follow the dragged gutter — the importer
fills it, and the exporter writes the page's real positions instead of
recomputing an even split, so the round trip keeps them.
`layout.marginsAndColumns` clears them when a count or gutter is set, which is
what InDesign's Margins and Columns does. Without that the stale positions would
outrank the new value and the dialog would look broken.
Verified against InDesign-authored IDML rather than assumed, which settled two
things. The attribute is written whether or not anything was dragged — a real
page's values are exactly its even split — so an even set is dropped rather than
marking every imported page custom and freezing its columns; the tolerance for
that comparison only absorbs float noise, about 1e-14 pt on that page. And
`<Guide>` is a `<Page>` child, not a `<Spread>` one, which is where this
importer already looks: the reporter's missing ruler guide was 099fbd2, landed
the day the ticket was filed, not a second bug.
That commit left `lib.rs` still listing guides as ignored on import; corrected.
* IDML: custom column positions are validated and round-trip (#1096)
Columns::positions are used only when they are 2 x count finite edges,
ascending from 0 to the margin box width (within 0.01 pt); anything else
is the even split, in Page::column_rects, on IDML import (bounded parse,
checked against the page's margin box) and on IDML export.
Columns::with_gutter_moved moves one gutter's pair of guides between its
neighbours for the coming column-guide command.
ROADMAP: drop four progress-log lines the #1121 merge duplicated and move
its line to the top.
* IDML: a page without its own margins checks the default's column positions against its width (#1096)
* ROADMAP: the progress line lands in one commit after the guide PRs
---------
Co-authored-by: Hamza <mail.hamzanaveed@gmail.com>
171 lines
8.1 KiB
Rust
171 lines
8.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 including dragged column positions,
|
|
//! units, grids), ruler guides, 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, gradient feather, directional feather), images (embedded `Contents` or linked), stories with paragraph/character style ranges and local overrides, special
|
|
//! characters and breaks, text threading, ruler and liquid guides, and stored page liquid rules.
|
|
//!
|
|
//! ## 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).
|
|
//! - Page-item overrides of parent items (only the page's `OverrideList` is read),
|
|
//! parent-of-parent page mapping beyond `AppliedMaster`, alternate layouts, liquid object constraints.
|
|
//! - 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, gradient feather stops between the first and last, its
|
|
//! midpoints and highlight, directional feather angle, noise, choke and shape mode, other effects
|
|
//! than drop shadow and the feathers, stroke/fill/content transparency, EPS/PDF/AI placed graphics (imported as images when the data is available),
|
|
//! clipping paths, compound-path fill rules.
|
|
//! - Placed PDFs: the page (`PageNumber`) and crop choice (`PDFCrop`) round-trip, and
|
|
//! `GraphicBounds` span the chosen box. Where that box sits on the page needs the PDF parsed,
|
|
//! which this crate doesn't do: the engine finds it after import (`file.openIdml`), and when the
|
|
//! PDF's data isn't available the page's crop box fills `GraphicBounds`. The content crops
|
|
//! (`CropContentVisibleLayers`, `CropContentAllLayers`) are the bounds of what the page draws:
|
|
//! its paths, text and images, each cut to the clip in force.
|
|
//! - 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 ruby;
|
|
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_report_with_previews, 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 (PNG, JPEG, GIF, WebP, TIFF, PDF, EPS).
|
|
pub(crate) fn sniff_image(b: &[u8]) -> (Option<&'static str>, Option<(u32, u32)>) {
|
|
if designcraft_images::is_eps(b) {
|
|
return (Some("application/postscript"), designcraft_images::eps_pixel_size(b));
|
|
}
|
|
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)
|
|
}
|