mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 04:36:47 -06:00
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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<Vec<f64>, _> = s.unwrap_or("").split_whitespace().take(7).map(str::parse::<f64>).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<Point> {
|
||||
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<Vec<String>> =
|
||||
|
||||
@@ -8,6 +8,7 @@ use designcraft_geom::{Rect, shapes};
|
||||
use super::*;
|
||||
|
||||
mod decorations;
|
||||
mod spread_rotation;
|
||||
|
||||
fn zip_files(files: &[(&str, &str)]) -> Vec<u8> {
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
@@ -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#"<Spread Self="sp1" PageCount="2" ItemTransform="1 0 0 1 0 0">"#,
|
||||
&format!(r#"<Spread Self="sp1" PageCount="2" ItemTransform="{xf}">"#),
|
||||
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<Vec<u8>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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% |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user