From d5d86217808234adfc69a0e238ded4d1116559cb Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 21:22:17 +0200 Subject: [PATCH] Pathfinder keeps the frontmost object, as InDesign does InDesign's Pathfinder result takes the attributes (and content) of the frontmost object, except Subtract, which keeps the backmost. DesignCraft kept the backmost for every op but Minus Back, so a photo placed over strips and intersected with them was deleted and the black strips stayed (Zero2Graphic's magazine tutorial, 7:19). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/paths.rs | 55 ++++++++++++++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 9f8168e50..4b25d802c 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-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. 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/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index b9d479067..9cfefdb4c 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { "Pathfinder", ["Object", "Pathfinder"], None, - "{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one (the backmost keeps its look; the frontmost for minusBack) → {id}", + "{op: add|subtract|intersect|exclude|minusBack, ids?} — combine the selected shapes into one, which keeps the frontmost object's look and content (a placed graphic stays in it); Subtract keeps the backmost → {id}", has_selection, pathfinder ), @@ -145,7 +145,9 @@ fn pathfinder(s: &mut Session, p: &Value) -> Result { if order.len() < 2 { return Err(bad("object.pathfinder", "select two or more shapes on one spread (not text frames or groups)")); } - let keep = if op == Op::MinusBack { order[order.len() - 1] } else { order[0] }; + // InDesign: the result takes the frontmost object's look and content (a photo placed over + // shapes shows through their intersection); Subtract keeps the backmost, the front cuts it. + let keep = if op == Op::Subtract { order[0] } else { order[order.len() - 1] }; let shapes: Vec = order.iter().filter_map(|id| spread_xf(d, *id).and_then(|(_, xf)| Some(d.item(*id)?.path.transformed(xf)))).collect(); let Some(result) = combine(op, &shapes) else { return Err(bad("object.pathfinder", "the shapes don't overlap: nothing is left")) }; @@ -417,6 +419,55 @@ mod tests { assert!(s.execute("object.pathfinder", &json!({"op": "nope"})).is_err()); } + /// The result keeps the frontmost object's look (Subtract: the backmost's), as InDesign's. + #[test] + fn pathfinder_keeps_the_frontmost_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("swatch.create", &json!({"name": "Red", "color": [255, 0, 0]})).unwrap(); + for op in ["add", "intersect", "exclude", "minusBack"] { + let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [200, 100, 400, 300]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a], "swatch": "[Black]"})).unwrap(); + s.execute("object.fill", &json!({"ids": [b], "swatch": "Red"})).unwrap(); + let r = s.execute("object.pathfinder", &json!({"op": op, "ids": [a, b]})).unwrap(); + assert_eq!(r["id"], b, "{op}: the front object stays"); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(a)).is_none(), "{op}"); + assert_eq!(d.item(ItemId(b)).unwrap().fill.swatch, "Red", "{op}"); + s.execute("edit.clear", &json!({"ids": [b]})).unwrap(); + } + } + + /// A photo placed over strips and intersected with them shows through the strips (it was + /// dropped: the black strips were kept). + #[test] + fn pathfinder_intersect_keeps_a_placed_photo_in_front() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [100, 100, 300, 140]})).unwrap()["id"].as_u64().unwrap(); + let b = s.execute("frame.create", &json!({"rect": [100, 160, 300, 200]})).unwrap()["id"].as_u64().unwrap(); + s.execute("object.fill", &json!({"ids": [a, b], "swatch": "[Black]"})).unwrap(); + let strips = s.execute("object.pathfinder", &json!({"op": "add", "ids": [a, b]})).unwrap()["id"].as_u64().unwrap(); + let png = designcraft_render::Rendered { width: 20, height: 20, pixels: [255u8, 255, 255, 255].repeat(400) }.to_png(); + let photo = s.execute("frame.create", &json!({"rect": [80, 80, 320, 220], "content": "graphic"})).unwrap()["id"].as_u64().unwrap(); + s.execute("file.place", &json!({"base64": super::super::base64_encode(&png), "name": "white.png", "frame": photo})).unwrap(); + s.execute("object.fit", &json!({"mode": "fillProportionally", "ids": [photo]})).unwrap(); + let r = s.execute("object.pathfinder", &json!({"op": "intersect", "ids": [strips, photo]})).unwrap(); + assert_eq!(r["id"], photo); + let d = s.doc().unwrap().doc.clone(); + assert!(d.item(ItemId(strips)).is_none()); + let it = d.item(ItemId(photo)).unwrap(); + assert!(matches!(it.content, Content::Graphic(_)), "the photo stays in the result"); + assert_eq!(it.path.subpaths.len(), 2, "cut to the two strips"); + let b = it.bounds(); + assert!((b.x0 - 100.0).abs() < 0.5 && (b.y0 - 100.0).abs() < 0.5 && (b.x1 - 300.0).abs() < 0.5 && (b.y1 - 200.0).abs() < 0.5, "{b:?}"); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + assert!(img.pixel(200, 120)[0] > 230, "the photo (white) shows in a strip: {:?}", img.pixel(200, 120)); + } + #[test] fn gradient_feather_fades_on_screen_and_in_pdf() { let mut s = Session::new(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d4a97fad6..a589ac3e6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -416,7 +416,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - File › Package (⌥⇧⌘P): the document relinked to a Links folder, the placed files, the font files that draw its text in a Document Fonts folder (the fonts it names and any fallback fonts that draw characters those lack; except fonts whose licence restricts it, OS/2 `fsType`), an IDML copy, an optional PDF and a report (fonts, fallback fonts, links, preflight, instructions); Copy Links To (`links.copyTo`). - Document fonts: opening a `.designcraft` or `.idml` file loads the fonts in the `Document Fonts` folder beside it (as Package writes it), ahead of installed fonts with the same name, so a packaged document opens with its fonts on another machine. Damaged, oversized (over 256 MB per file, 512 MB in all) and surplus (over 200) font files are skipped and listed in `file.open`'s `warnings`; `font.list` gives each font's `source`. Document fonts belong to their document, as in InDesign: other open documents don't see them (each document with a same-named font draws with its own), and closing the document (or reverting it, or a book chapter's export ending) makes them unavailable. Their files stay in memory until DesignCraft quits, so reopening a document doesn't read or load them again; on the web there is no folder to read. - Load Swatches / Save Swatches for Exchange (.ase: CMYK, RGB, Lab, Gray; spot or process) from the Swatches panel menu; `swatch.load` / `swatch.save`. -- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); `object.pathfinder`. +- Pathfinder (Object › Pathfinder and a Pathfinder panel): Add, Subtract, Intersect, Exclude Overlap, Minus Back; curves stay curves (flo_curves); the result keeps the frontmost object's look and content, so a photo intersected with shapes behind it shows through them (Subtract keeps the backmost); `object.pathfinder`. - Make / Release Compound Path (⌘8; nested paths become holes), Create Outlines (⇧⌘O; one path per text colour), Scissors tool (C) with `path.split`. - Transform Again / Individually / Sequence Again (⌥⌘3; a moved copy repeats like Step and Repeat), Clear Transformations; Layers: Merge, Delete Unused, Hide/Lock Others, Show/Unlock All; Break Link to Style (paragraph and character). - Floating panels: tear a panel off the dock (its float button, or drag its header away) into a movable, resizable window; Dock puts it back; positions persist; `window.floatPanel` / `window.dockPanel`.