From a8fc6a521999ee7688675acb5167a006fdb3eb2a Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 11 Oct 2026 20:11:05 +0000 Subject: [PATCH] IDML: read and write a spread's view rotation (#484) Rotate Spread is the rotation part of the Spread element's ItemTransform. Import maps the three quarter-turn matrices to view_rotation on the spread's first page and treats anything else as no rotation; pages and items keep their unrotated spread coordinates. Export writes the rotation part for document spreads; parent spreads stay identity. --- ROADMAP.md | 2 + crates/idml/src/export.rs | 13 +++- crates/idml/src/import.rs | 23 ++++++- crates/idml/src/tests.rs | 1 + crates/idml/src/tests/spread_rotation.rs | 76 ++++++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/target-app-parity.md | 2 +- 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 crates/idml/src/tests/spread_rotation.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9a0f71e70..3687f4907 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-11** · IDML reads and writes a spread's view rotation (Rotate Spread) as the + rotation part of the spread transform (#484). - **2026-10-10 (UI.4–UI.5):** Panel tabs can move into other groups, split the dock, float, resize and return to their saved locations through shared docking transactions. Layouts persist in custom workspaces; UI and control commands share the same validated action path. Shared pane geometry and widget-state theme application retain all five palettes and local fonts. Split panes send keyboard input only to the focused canvas and scope contextual toolbar IDs per pane. No readiness percentage change. - **2026-10-10** · Version 0.6.0; releases add a Linux riscv64 tar.gz (cross-compiled, CLI smoke-tested under QEMU). diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..5fbf82e03 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1572,7 +1572,18 @@ impl<'a> Ex<'a> { el.set("BindingLocation", sp.pages.iter().filter(|p| p.side == PageSide::Left).count()); el.set("AllowPageShuffle", bool_s(sp.allow_shuffle)); } - el.set("ItemTransform", "1 0 0 1 0 0"); + // View › Rotate Spread is the rotation part of the spread's transform; pages and items stay in + // unrotated spread coordinates. + let rotation = if master { 0 } else { sp.pages.first().map_or(0, |p| p.view_rotation) }; + el.set( + "ItemTransform", + match rotation { + 1 => "0 1 -1 0 0 0", + 2 => "-1 0 0 -1 0 0", + 3 => "0 -1 1 0 0 0", + _ => "1 0 0 1 0 0", + }, + ); let based_on = sp.parent.as_ref().and_then(|i| i.based_on); for (i, pg) in sp.pages.iter().enumerate() { let name = if master { sp.parent.as_ref().map(|i| i.prefix.clone()).unwrap_or_default() } else { d.page_name(first_page + i) }; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d85d9ee02..95dfbda32 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -163,6 +163,26 @@ fn parse_affine(s: Option<&str>) -> Affine { } } +/// View › Rotate Spread from a `Spread` element's `ItemTransform`: quarter turns clockwise on +/// screen (IDML y points down, so `0 1 -1 0` is one clockwise turn). The spread's items and +/// pages stay in unrotated spread coordinates, so only the rotation part is read. Anything other +/// than an exact quarter turn (identity, scale, other angles, malformed numbers) is no rotation. +fn spread_view_rotation(s: Option<&str>) -> u8 { + let parsed: std::result::Result, _> = s.unwrap_or("").split_whitespace().take(7).map(str::parse::).collect(); + let Some([a, b, c, d, _, _]) = parsed.ok().and_then(|v| <[f64; 6]>::try_from(v).ok()) else { return 0 }; + let is = |x: f64, y: f64| (x - y).abs() < 1e-6; + let quarter = |m: [f64; 4]| is(a, m[0]) && is(b, m[1]) && is(c, m[2]) && is(d, m[3]); + if quarter([0.0, 1.0, -1.0, 0.0]) { + 1 + } else if quarter([-1.0, 0.0, 0.0, -1.0]) { + 2 + } else if quarter([0.0, -1.0, 1.0, 0.0]) { + 3 + } else { + 0 + } +} + fn parse_point(s: Option<&str>) -> Option { let v = nums(s?); (v.len() >= 2).then(|| Point::new(v[0], v[1])) @@ -1844,6 +1864,7 @@ impl<'r> Importer<'r> { let shift = Affine::translate((-min_x, -min_y)); let show = e.get("ShowMasterItems") != Some("false"); let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum(); + let view_rotation = if master { 0 } else { spread_view_rotation(e.get("ItemTransform")) }; let mut out_pages = Vec::new(); for (i, (r, p)) in pages.iter().enumerate() { let pid = PageId(self.alloc()); @@ -1878,7 +1899,7 @@ impl<'r> Importer<'r> { show_parent_items: show, liquid: Default::default(), transition: None, - view_rotation: 0, + view_rotation: if i == 0 { view_rotation } else { 0 }, }); } let overrides: Vec> = diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 7b747587a..4b2507633 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -8,6 +8,7 @@ use designcraft_geom::{Rect, shapes}; use super::*; mod decorations; +mod spread_rotation; fn zip_files(files: &[(&str, &str)]) -> Vec { use zip::write::SimpleFileOptions; diff --git a/crates/idml/src/tests/spread_rotation.rs b/crates/idml/src/tests/spread_rotation.rs new file mode 100644 index 000000000..8f51e5676 --- /dev/null +++ b/crates/idml/src/tests/spread_rotation.rs @@ -0,0 +1,76 @@ +//! View › Rotate Spread lives in the `Spread` element's `ItemTransform`. + +use std::sync::Arc; + +use super::*; + +fn fixture_with_spread_transform(xf: &str) -> Document { + let spread = SPREAD.replacen( + r#""#, + &format!(r#""#), + 1, + ); + assert!(spread.contains(&format!(r#"ItemTransform="{xf}">"#))); + import_idml(&zip_files(&[ + ("designmap.xml", DESIGNMAP), + ("Resources/Graphic.xml", GRAPHIC), + ("Resources/Styles.xml", STYLES), + ("Resources/Preferences.xml", PREFS), + ("MasterSpreads/MasterSpread_m1.xml", MASTER), + ("Spreads/Spread_sp1.xml", &spread), + ("Stories/Story_s1.xml", STORY), + ])) + .unwrap() +} + +fn rotations(d: &Document) -> Vec> { + d.spreads.iter().map(|s| s.pages.iter().map(|p| p.view_rotation).collect()).collect() +} + +fn assert_same_geometry(a: &Document, b: &Document) { + assert_eq!(a.spreads.len(), b.spreads.len()); + for (sa, sb) in a.spreads.iter().zip(&b.spreads) { + assert_eq!(sa.pages.len(), sb.pages.len()); + for (pa, pb) in sa.pages.iter().zip(&sb.pages) { + assert_eq!((pa.x, pa.width, pa.height, pa.side), (pb.x, pb.width, pb.height, pb.side)); + } + assert_eq!(sa.items.len(), sb.items.len()); + for (ia, ib) in sa.items.iter().zip(&sb.items) { + assert_eq!(ia.xf, ib.xf); + assert_eq!(ia.bounds(), ib.bounds()); + } + } +} + +#[test] +fn spread_transform_rotation_imports_as_view_rotation_without_moving_geometry() { + let plain = fixture_with_spread_transform("1 0 0 1 0 0"); + assert_eq!(rotations(&plain), vec![vec![0, 0]]); + for (xf, turns) in [("0 1 -1 0 0 350", 1), ("-1 0 -0 -1 0 0", 2), ("0 -1 1 0 0 0", 3), ("0.0000001 1 -1 0 0 0", 1)] { + let d = fixture_with_spread_transform(xf); + assert_eq!(rotations(&d), vec![vec![turns, 0]], "{xf}"); + assert_same_geometry(&plain, &d); + } + for xf in ["2 0 0 2 0 0", "0.7071 0.7071 -0.7071 0.7071 0 0", "0 1 -1 0 0", "0 1 x -1 0 0 0", "NaN 1 -1 0 0 0", "inf 0 0 -inf 0 0", ""] { + let d = fixture_with_spread_transform(xf); + assert_eq!(rotations(&d), vec![vec![0, 0]], "{xf}"); + assert_same_geometry(&plain, &d); + } +} + +#[test] +fn view_rotation_round_trips() { + for r in 0..4u8 { + let mut d = small_doc(); + let n = d.spreads.len() as u8; + for (i, s) in d.spreads.iter_mut().enumerate() { + Arc::make_mut(s).pages[0].view_rotation = (r + i as u8) % 4; + } + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(rotations(&back), rotations(&d), "rotation {r} over {n} spreads"); + assert!(back.parents.iter().all(|s| s.pages.iter().all(|p| p.view_rotation == 0))); + let mut unrotated = d.clone(); + unrotated.spreads.iter_mut().for_each(|s| Arc::make_mut(s).pages[0].view_rotation = 0); + assert_same_geometry(&import_idml(&export_idml(&unrotated)).unwrap(), &back); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 90777577e..f1cd3d71d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ score below). |---|---|---|---|---|---|---| | InDesign document | `.indd` | read/write (native) | ✗ (#21; converters proposed in #98, #114) | ✗ | — | 30% | | InDesign template | `.indt` | read/write | ✗ | ✗ | — | 2% | -| InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (49 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | +| InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (51 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% | | DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — | | InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% | | InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8044ed51b..6f6b32c35 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -370,7 +370,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Colour management exposed: Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. - Keyboard: Tab / Shift+Tab hide and show all panels / all but Tools (`window.hidePanels`, `window.hidePanelsExceptTools`); Cmd+F6 / Cmd+Shift+F6 cycle documents; panel fields are reached with Tab and Escape returns to the layout. - Smart guides and snapping (View ▸ Snap to Guides / Snap to Document Grid / Smart Guides, `view.snapPreferences` and Preferences ▸ Guides & Pasteboard): moving, drawing, resizing, rotating and pen/anchor drags snap in order to the document and baseline grids, ruler guides, margins and columns, other objects' edges and centres (parent-page items too, by visible stroked bounds), equal spacing, matching dimensions and matching rotation, with guides, gap marks and measurements drawn on the canvas. Shift and Cmd constrain the gesture. -- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. +- View ▸ Rotate Spread (`view.rotateSpread`, `layout.rotateSpreadView`): the spread in view turns in quarter turns on screen (stored with the document and in IDML's spread transform; output is unaffected). The canvas maps each spread through its own transform, so tools, snapping, resize handles, guides, overlays and rendering all follow the turned spread. - Vertical type (Vertical Type Tool, Type ▸ Story Direction `type.storyDirection`, IDML StoryOrientation): the direction belongs to the story, so every frame of a thread is vertical or horizontal together and a frame threaded onto a story takes its direction (`object.textFrameOptions {vertical}` sets the frames' stories); lines run top to bottom and follow each other right to left; the arrow keys follow them (Down and Up along the line, Left and Right to the next and previous line, keeping the position along it); CJK characters stay upright and other text turns, on screen and in PDF; CJK line breaking with basic kinsoku (also in horizontal text); Korean breaks at spaces, word by word (Hangul in any language, hanja in Korean text), or between syllables when the paragraph asks for it (`type.para {koreanCharBreaks}`, Paragraph Style Options ▸ Justification; not written to IDML). Upright text is shaped top to bottom: punctuation and brackets take their vertical forms (OpenType `vert`; `vkrn` and `vpal` through OpenType features), glyphs advance by their vertical metrics (`vmtx`) and hang from their vertical origins (`VORG`) on the centre of the ideographic em box (`BASE`, else OS/2), which the first line's ascent follows; turned text keeps its horizontal forms. Tate-chu-yoko (Type ▸ Tate-Chu-Yoko, `type.tateChuYoko`, IDML `Tatechuyoko`) sets a run such as "12" side by side within one em of the line. Ruby (Type ▸ Ruby…, `type.ruby`, IDML RubyString; group ruby spread 1-2-1 or centred) and kenten emphasis dots (`type.kenten`, IDML KentenKind) are set over their text — to its right in vertical frames — without changing line breaks. Warichu and ruby options (per-character ruby, positions, overhang) aren't there yet. - Math expressions (Type ▸ Insert Math Expression…, `math.insert`, `math.svg`): a LaTeX subset (fractions, scripts, roots, big operators with limits, Greek, relations and operators with math spacing, `\left…\right`, `\text`) typeset by our own engine into SVG, placed inline at the cursor or as a frame; the LaTeX is kept as alt text and can be edited. - Object Layer Options for placed PDFs (`object.pdfLayers`, `object.layerOptions`, Object ▸ Object Layer Options…): the file's optional-content layers listed and shown or hidden per frame on screen; in exported PDFs such graphics go out as 300 ppi images of their visible layers.