diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..bad2c9d04 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · IDML: ruler guides import and export (they were dropped). Each page's guides come in measured from the document's ruler origin (spread, page or spine), with Fit to Page, Locked and their layer, parent pages' included, and go out again under their pages. No readiness percentage change. - **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change. - **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change. - **2026-10-14** · The Swatches panel has InDesign's header: a Fill/Stroke proxy, Formatting Affects Container/Text and the Tint of the colour in front (a tint alone keeps each object's own swatch). Clicking a swatch applies it to the fill or stroke in front (of the text, when formatting affects text), and the bottom row has New Color Group, New Swatch and Delete Swatch. No readiness percentage change. diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 0b9435c71..1a5c9df77 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1667,6 +1667,26 @@ impl<'a> Ex<'a> { .attr("GeometricBounds", format!("0 0 {} {}", num(pg.height), num(pg.width))) .attr("ItemTransform", format!("1 0 0 1 {} {}", num(pg.x - ox), num(-oy))) .child(margin_el(pg)); + let mut pe = pe; + // Ruler guides, measured from the spread's top left (RulerOrigin is SpreadOrigin). + let left = sp.pages.iter().map(|p| p.x).fold(f64::INFINITY, f64::min); + let left = if left.is_finite() { left } else { 0.0 }; + for g in &pg.guides { + let vertical = g.orientation == designcraft_doc::Orientation::Vertical; + let mut ge = El::new("Guide") + .attr("Self", format!("{}g{}", uid(pg.id.0), pe.elements().count())) + .attr("Orientation", if vertical { "Vertical" } else { "Horizontal" }) + .attr("Location", num(if vertical { g.position - left } else { g.position })) + .attr("FitToPage", bool_s(!g.spread)) + .attr("ViewThreshold", "5") + .attr("Locked", bool_s(g.locked)) + .attr("PageIndex", (first_page + i).to_string()) + .attr("GuideType", "Ruler"); + if let Some(l) = g.layer { + ge.set("ItemLayer", uid(l.0)); + } + pe.push(ge); + } el.push(pe); } let origin = Affine::translate((-ox, -oy)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 51dc66d84..7dd5867b6 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -9,8 +9,8 @@ use designcraft_color::{Color, ColorType, Gradient, GradientKind, GradientStop, use designcraft_doc::{ Asset, AssetId, BaselineGrid, CellRange, CharAttrs, CharFormat, CharRun, CharacterStyle, Columns, ColumnsKind, Content, DocSettings, Document, DropShadow, Effects, Fill, GridAlign, GridRelative, Guide, Item, ItemId, Kerning, LAYER_COLORS, Layer, LayerId, ListType, Margins, ObjectStyle, - Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, SpreadId, - Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, + Orientation, Page, PageId, PageSide, ParaAttrs, ParaFormat, ParagraphStyle, ParentInfo, RowHeightMode, Rule, Section, Shape, SpanColumns, Spread, + SpreadId, Story, StoryId, Stroke, Styles, TabStop, Table, TextFrame, TextFrameOptions, TextWrap, story as st, }; use designcraft_geom::corners::{Corner, CornerOptions}; use designcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath}; @@ -116,6 +116,14 @@ pub fn import_idml_report(bytes: &[u8], read_link: &dyn Fn(&str) -> Option String { @@ -159,6 +167,8 @@ struct ItemCtx { struct Importer<'r> { read_link: &'r dyn Fn(&str) -> Option>, + /// View Preferences › Ruler Origin: where a guide's Location is measured from. + ruler_origin: RulerOrigin, next_id: u64, settings: DocSettings, default_margins: Option<(Margins, Columns)>, @@ -275,6 +285,7 @@ impl<'r> Importer<'r> { styles.paragraph.iter_mut().for_each(|s| s.based_on = None); Importer { read_link, + ruler_origin: RulerOrigin::Spread, next_id: 0, settings: DocSettings::default(), default_margins: None, @@ -362,6 +373,11 @@ impl<'r> Importer<'r> { if let Some(v) = e.num("CursorKeyIncrement") { self.settings.keyboard_increment = v; } + self.ruler_origin = match e.get("RulerOrigin") { + Some("PageOrigin") => RulerOrigin::Page, + Some("SpineOrigin") => RulerOrigin::Spine, + _ => RulerOrigin::Spread, + }; } "GridPreference" => self.grid_prefs(e), "PasteboardPreference" => { @@ -1964,6 +1980,33 @@ impl<'r> Importer<'r> { // ---------- spreads and items ---------- + /// Ruler guides of a `` (its `` children), `page` its rectangle in our spread + /// coordinates. A guide's Location is measured from the ruler origin: the spread's top left, the + /// page's, or the spine. Fit to Page off: the guide crosses the spread. + fn guides_of(&self, p: &El, page: Rect, spine: f64) -> Vec { + p.find_all("Guide") + .filter_map(|g| { + let at = g.num("Location").filter(|v| v.is_finite() && v.abs() < 1e7)?; + let orientation = if g.get("Orientation") == Some("Horizontal") { Orientation::Horizontal } else { Orientation::Vertical }; + let position = match (orientation, self.ruler_origin) { + (Orientation::Horizontal, RulerOrigin::Page) => page.y0 + at, + (Orientation::Horizontal, _) => at, + (Orientation::Vertical, RulerOrigin::Page) => page.x0 + at, + (Orientation::Vertical, RulerOrigin::Spine) => spine + at, + (Orientation::Vertical, RulerOrigin::Spread) => at, + }; + Some(Guide { + orientation, + position, + spread: g.get("FitToPage") == Some("false"), + locked: g.get("Locked") == Some("true"), + layer: g.get("ItemLayer").and_then(|l| self.layer_ids.get(l)).copied(), + liquid: false, + }) + }) + .collect() + } + fn spread(&mut self, e: &El, id: SpreadId, master: bool) -> Spread { let facing = self.settings.facing_pages; // Page rectangles in IDML spread coordinates. @@ -1981,6 +2024,9 @@ impl<'r> Importer<'r> { let min_y = pages.iter().map(|(r, _)| r.y0).fold(f64::INFINITY, f64::min); let (min_x, min_y) = if min_x.is_finite() { (min_x, min_y) } else { (0.0, 0.0) }; let shift = Affine::translate((-min_x, -min_y)); + // The spine: between a left and a right page (else the spread's left edge). + let spine = pages.iter().map(|(r, _)| r.x0 - min_x).filter(|x| *x > 1e-6).fold(f64::INFINITY, f64::min); + let spine = if spine.is_finite() && facing { spine } else { 0.0 }; let show = e.get("ShowMasterItems") != Some("false"); let first_abs: usize = self.spreads.iter().map(|s| s.pages.len()).sum(); let mut out_pages = Vec::new(); @@ -2013,7 +2059,7 @@ impl<'r> Importer<'r> { parent, side, overridden: vec![], - guides: Vec::::new(), + guides: self.guides_of(p, Rect::new(r.x0 - min_x, r.y0 - min_y, r.x1 - min_x, r.y1 - min_y), spine), show_parent_items: show, liquid: Default::default(), transition: None, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 57801e34f..934281734 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2169,3 +2169,54 @@ fn frame_preference_property_color_resolves_for_frames_and_object_styles() { assert_eq!(colors, vec!["Near", "Near", "Far", designcraft_color::swatch::NONE]); assert_eq!(d.styles.object_style("Child").unwrap().text_frame.as_ref().unwrap().column_rule_color, "Near"); } + +/// A two-page spread with ruler guides on its pages (and one on the parent), in `origin` ruler +/// coordinates. +fn guides_package(origin: &str) -> Vec { + let prefs = PREFS.replace("", &format!(r#""#)); + let spread = SPREAD.replace( + r#""#, + r#" + + + "#, + ); + let master = MASTER.replace( + r#""#, + r#""#, + ); + 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), + ]) +} + +/// Ruler guides come in from IDML (they were dropped): on their page, measured from the ruler +/// origin, with Fit to Page, Locked and their layer; parent pages' too. They go out again. +#[test] +fn ruler_guides_import_and_round_trip() { + use designcraft_doc::Orientation::{Horizontal, Vertical}; + let guides = |d: &Document| { + let p = &d.spreads[0].pages[1]; + p.guides.iter().map(|g| (g.orientation, g.position, g.spread, g.locked, g.layer.is_some())).collect::>() + }; + // The second page starts at x 500 in our spread coordinates. + let d = import_idml(&guides_package("PageOrigin")).unwrap(); + assert_eq!(guides(&d), [(Vertical, 540.0, false, true, true), (Horizontal, 100.0, true, false, false)]); + let d = import_idml(&guides_package("SpineOrigin")).unwrap(); + assert_eq!(guides(&d)[0].1, 540.0, "the spine is the second page's left edge"); + let d = import_idml(&guides_package("SpreadOrigin")).unwrap(); + assert_eq!(guides(&d)[0].1, 40.0); + assert_eq!(d.parents[0].pages[0].guides.len(), 1, "a parent page's guide"); + assert_eq!(d.parents[0].pages[0].guides[0].position, 84.0); + // Round trip: written under each page, read back where they were. + let d = import_idml(&guides_package("PageOrigin")).unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(guides(&back), guides(&d)); + assert_eq!(back.parents[0].pages[0].guides.len(), 1); +}