From 5f60a833286cde64c96b8b4d8fd7c05f0098fe11 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 03:25:47 -0400 Subject: [PATCH] =?UTF-8?q?D:=20Adjust=20Layout=20=E2=80=94=20objects=20fo?= =?UTF-8?q?llow=20page=20size=20and=20margin=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 108 +++++++++++++++++++++++++++++++- crates/ui-egui/src/dialogs.rs | 3 +- docs/parity.md | 10 +-- 4 files changed, 114 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 236e320..2723a31 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -76,6 +76,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Gridify: arrow keys while dragging a frame or shape tool add columns/rows (`frame.grid`), one undo step. - Gap tool (U, `gap.move`): drag the space between objects (or an object and the page edge) and both sides resize. - XML basics (`xml.newTag/deleteTag/tag/mapStyle/structure`, `file.exportXml`, `file.importXml`; Tags panel with structure; View ▸ Show Tagged Frames): tagged frames export in reading order, mapped paragraph styles become elements, imported elements flow into frames with matching tags. Inline text tagging and DTDs aren't there yet. +- Adjust Layout / Layout Adjustment (`adjustLayout` on Document Setup and Margins and Columns): objects follow each page's margin box to its new size and position. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 0486f0d..9e7be84 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -6,6 +6,75 @@ use serde_json::{Value, json}; use super::{CommandSpec, bad, cmd, has_doc, ok, str_param}; use crate::Result; +/// Each page's margin box, by spread and page id. +fn margin_boxes(d: &designcraft_doc::Document) -> Vec<(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)> { + d.spread_refs() + .filter_map(|r| d.spread(r).map(|sp| (r, sp))) + .flat_map(|(r, sp)| sp.pages.iter().map(move |pg| (r, pg.id, pg.margin_rect()))) + .collect() +} + +/// Layout Adjustment: objects on each page move and resize from its old margin box to its new +/// one (content keeps its size; frames that auto-fit refit). +fn adjust_layout(d: &mut designcraft_doc::Document, before: &[(designcraft_doc::SpreadRef, designcraft_doc::PageId, designcraft_geom::Rect)]) { + use designcraft_geom::Affine; + let after = margin_boxes(d); + for (r, pid, old) in before { + let Some((_, _, new)) = after.iter().find(|(r2, p2, _)| r2 == r && p2 == pid) else { continue }; + if old == new || old.width() < 1e-6 || old.height() < 1e-6 { + continue; + } + let m = Affine::translate((new.x0, new.y0)) + * Affine::scale_non_uniform(new.width() / old.width(), new.height() / old.height()) + * Affine::translate((-old.x0, -old.y0)); + let Some(sp) = d.spread(*r) else { continue }; + // The page's objects (by centre, measured against the old boxes). + let ids: Vec = sp + .items + .iter() + .filter(|it| { + let c = it.bounds().center(); + // Pages share a spread side by side: the margin box's page is the one under the centre's x. + before + .iter() + .filter(|(r2, _, _)| r2 == r) + .min_by(|a, b| dist(a.2, c.x).total_cmp(&dist(b.2, c.x))) + .is_some_and(|(_, p2, _)| p2 == pid) + }) + .map(|it| it.id) + .collect(); + for id in ids { + let Some(it) = d.item_mut(id) else { continue }; + if matches!(it.content, designcraft_doc::Content::Group { .. }) { + it.xf = m * it.xf; + } else { + let inner = it.xf.inverse() * m * it.xf; + it.path.transform(inner); + let ib = it.inner_bounds(); + if let designcraft_doc::Content::Graphic(g) = &mut it.content { + // Content keeps its size but moves with the frame; auto-fit refits. + let o = inner * designcraft_geom::Point::ZERO; + g.xf = Affine::translate(o.to_vec2()) * g.xf; + if let Some(xf) = g.fitted(ib, g.auto_fit) { + g.xf = xf; + } + } + } + } + } +} + +/// Horizontal distance from `x` to a margin box (0 inside). +fn dist(r: designcraft_geom::Rect, x: f64) -> f64 { + if x < r.x0 { + r.x0 - x + } else if x > r.x1 { + x - r.x1 + } else { + 0.0 + } +} + pub fn specs() -> Vec { vec![ cmd!( @@ -104,13 +173,15 @@ pub fn specs() -> Vec { "Margins and Columns…", ["Layout"], None, - "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?}", + "{pages?: [index] (default all), margins?: number|{top,bottom,inside,outside}, columns?, gutter?, adjustLayout?: bool (objects follow the margins)}", has_doc, |s, p| { let pages: Option> = p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|v| v as usize).collect()); let p = p.clone(); + let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { + let before = adjust.then(|| margin_boxes(d)); let all: Vec = pages.clone().unwrap_or_else(|| (0..d.page_count()).collect()); for abs in all { let Some((si, pi)) = d.page_loc(abs) else { continue }; @@ -131,6 +202,9 @@ pub fn specs() -> Vec { pg.columns.gutter = g.max(0.0); } } + if let Some(b) = before { + adjust_layout(d, &b); + } ok() }) } @@ -140,7 +214,7 @@ pub fn specs() -> Vec { "Document Setup…", ["File"], Some("Cmd+Alt+P"), - "{width?, height?, pages?: count, startPage?: n, facingPages?, intent?: print|web|mobile, bleed?, slug?: n | [top, bottom, inside, outside]} → the document setup", + "{width?, height?, pages?: count, startPage?: n, facingPages?, intent?: print|web|mobile, bleed?, slug?: n | [top, bottom, inside, outside], adjustLayout?: bool (objects follow the new page size)} → the document setup", has_doc, |s, p| { let p = p.clone(); @@ -159,7 +233,9 @@ pub fn specs() -> Vec { let (bleed, slug) = (edges("bleed"), edges("slug")); let intent: Option = p.get("intent").and_then(|v| serde_json::from_value(v.clone()).ok()); let pages = p.get("pages").and_then(Value::as_u64).map(|n| n.clamp(1, 9999) as usize); + let adjust = p.get("adjustLayout").and_then(Value::as_bool).unwrap_or(false); s.edit(|d, _| { + let before = adjust.then(|| margin_boxes(d)); if let Some(f) = p.get("facingPages").and_then(Value::as_bool) { d.settings.facing_pages = f; } @@ -215,6 +291,9 @@ pub fn specs() -> Vec { } } d.repaginate(); + if let Some(b) = before { + adjust_layout(d, &b); + } Ok(document_setup(d)) }) } @@ -705,3 +784,28 @@ mod page_numbering_view_tests { assert_eq!(s.resolve_page("9"), None); } } + +#[cfg(test)] +mod adjust_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn adjust_layout_follows_the_new_page_size_and_margins() { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 1, "facingPages": false})).unwrap(); + // Letter, 36 pt margins: a frame filling the left half of the margin box. + let id = s.execute("frame.create", &json!({"rect": [36, 36, 306, 756]})).unwrap()["id"].as_u64().unwrap(); + s.execute("layout.documentSetup", &json!({"width": 1224, "adjustLayout": true})).unwrap(); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert!((b.x0 - 36.0).abs() < 1e-6 && (b.x1 - 612.0).abs() < 1e-6, "half of the wider margin box: {b:?}"); + s.execute("layout.marginsAndColumns", &json!({"margins": 72, "adjustLayout": true})).unwrap(); + let b = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert!((b.x0 - 72.0).abs() < 1e-6 && (b.y0 - 72.0).abs() < 1e-6 && (b.y1 - 720.0).abs() < 1e-6, "{b:?}"); + // Without the option objects stay put. + s.execute("layout.marginsAndColumns", &json!({"margins": 36})).unwrap(); + let b2 = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + assert_eq!(b, b2); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 276c294..174f3b0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -361,6 +361,7 @@ fn document_setup(ui: &mut egui::Ui, d: &mut Dialog) { ui.end_row(); } }); + check(ui, d, "adjustLayout", "Adjust Layout (objects follow the new page size)"); } /// Preferences: a section list and the section's options. Application options always; units and @@ -1158,7 +1159,7 @@ pub fn confirm(app: &mut DesignApp) -> Result { app.run( "layout.documentSetup", json!({"intent": d.s("intent"), "pages": d.n("pages").unwrap_or(1.0).max(1.0) as u64, "startPage": d.n("startPage").unwrap_or(1.0).max(1.0) as u64, - "facingPages": d.b("facingPages"), "width": d.m("width"), "height": d.m("height"), "bleed": edges("bleed"), "slug": edges("slug")}), + "facingPages": d.b("facingPages"), "width": d.m("width"), "height": d.m("height"), "bleed": edges("bleed"), "slug": edges("slug"), "adjustLayout": d.b("adjustLayout")}), ) } "paragraphStyleOptions" => { diff --git a/docs/parity.md b/docs/parity.md index f17b652..c1ed7b4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,18 +5,18 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. -**Weighted breadth parity: 91%** over 275 features. +**Weighted breadth parity: 92%** over 275 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 92 | 4 | 1 | 97% | -| P2 | 76 | 27 | 18 | 31 | 47% | +| P2 | 76 | 29 | 18 | 29 | 50% | | Area | Parity | |---|---| | Application shell & workspace | 82% | -| Documents, pages, spreads | 87% | +| Documents, pages, spreads | 90% | | Layers | 95% | | Frames, shapes & paths | 99% | | Transform | 100% | @@ -76,8 +76,8 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Documents, pages, spreads | Parent item overrides | P0 | D | | Documents, pages, spreads | Page transitions | P2 | M | | Documents, pages, spreads | Spread view rotation | P2 | M | -| Documents, pages, spreads | Layout Adjustment | P2 | M | -| Documents, pages, spreads | Adjust Layout | P2 | M | +| Documents, pages, spreads | Layout Adjustment | P2 | D | +| Documents, pages, spreads | Adjust Layout | P2 | D | | Documents, pages, spreads | Liquid layout | P2 | M | | Documents, pages, spreads | Alternate layouts | P2 | M | | Documents, pages, spreads | Document grid & baseline grid | P0 | D |