From cb086ef1b7d3417c174ffb1b1ca3e232d8cbb559 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:03:43 -0800 Subject: [PATCH 1/6] Part of #6361: Cut takes direct-selected anchors out of the path With anchors picked with Direct Selection, Edit > Cut removed only the segments between them, so the picked anchors stayed in the path. Cut now removes picked anchors and the segments attached to them from a path or a compound path's member, as Clear does. Picked segments still lose only those segments. When a path has picked segments, Copy and Cut leave out anchors picked on its other contours, as Clear does. Docs: docs/target-app-parity.md. --- crates/engine/src/cmd/clipboard.rs | 3 +- crates/engine/src/cmd/edit.rs | 6 +- crates/engine/src/cmd/path.rs | 2 +- crates/engine/src/tests/draw2.rs | 12 +-- .../engine/tests/it/copy_direct_selection.rs | 90 +++++++++++++++++-- docs/target-app-parity.md | 1 + 6 files changed, 97 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 6d1e7284c..92d01bb7e 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -115,7 +115,8 @@ pub(crate) fn subpath_selected_segments( let n = sp.anchors.len(); let mut selected_segs = BTreeSet::new(); - let has_explicit_segs = seg_refs.is_some_and(|segments| segments.iter().any(|&(s, _)| s == si)); + // Segments picked anywhere on the path leave anchors out of it, as Clear does. + let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty()); if has_explicit_segs { if let Some(segments) = seg_refs { for &(s, seg) in segments { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 5809835e3..80574d2d5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1063,8 +1063,12 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result { let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs); if cuts.is_empty() { subpaths.push(sp); - } else { + } else if seg_refs.is_some_and(|segments| !segments.is_empty()) { subpaths.extend(super::path::without_segments(sp, &cuts)); + } else { + // Picked anchors leave the path with their connections, as Clear removes them. + let gone: BTreeSet = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect(); + subpaths.extend(super::path::without_anchors(sp, &gone)); } } subpaths.retain(|sp| sp.anchors.len() >= 2); diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index cb82cfe3f..5d7f93581 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -1329,7 +1329,7 @@ pub(super) fn replace_deleted_pieces(d: &mut Document, beside: &mut Beside, orig /// Remove anchors and their incident segments, keeping each surviving stretch as an open path. /// Only a closed path can have a surviving stretch that wraps past the last anchor. -fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { +pub(super) fn without_anchors(sp: SubPath, removed: &BTreeSet) -> Vec { let n = sp.anchors.len(); let gone: BTreeSet = removed.iter().copied().filter(|&i| i < n).collect(); if gone.is_empty() { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index 7a05a5896..c0946a465 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -2698,19 +2698,15 @@ fn pen_cmd_selected_anchors_are_what_copy_and_cut_take() { assert_eq!(s.doc().unwrap().selection.anchors[&id], [(0, 1), (0, 2)].into()); s.execute("edit.copy", &json!({})).unwrap(); assert_eq!(pasted(&mut s), [2]); - // Cut takes the segment between them. The two surviving runs of an ordinary path - // become independent objects, and this path keeps the first run (#3380). + // Cut takes the two anchors out of the path, as Clear does (#6361): each side keeps one + // anchor, which makes no path, so nothing is left. s.execute("edit.undo", &json!({})).unwrap(); let original = path(&s, id); let picked = s.doc().unwrap().selection.clone(); s.execute("edit.cut", &json!({})).unwrap(); let left = paths(&s); - assert_eq!(left.len(), 2); - assert_eq!(left[0].0, id); - assert!(left.iter().all(|(_, p)| p.subpaths.len() == 1 && !p.subpaths[0].closed)); - assert_eq!(left[0].1.subpaths[0].anchors, original.subpaths[0].anchors[..2]); - assert_eq!(left[1].1.subpaths[0].anchors, original.subpaths[0].anchors[2..]); - assert_eq!(s.doc().unwrap().selection.objects, left.iter().map(|(id, _)| *id).collect::>()); + assert!(left.is_empty()); + assert!(s.doc().unwrap().selection.objects.is_empty()); assert_eq!(s.clipboard.nodes.len(), 1); assert_eq!(s.clipboard.nodes[0].path_data().unwrap().subpaths[0].anchors, original.subpaths[0].anchors[1..3]); s.execute("edit.undo", &json!({})).unwrap(); diff --git a/crates/engine/tests/it/copy_direct_selection.rs b/crates/engine/tests/it/copy_direct_selection.rs index 957d77829..c2713befe 100644 --- a/crates/engine/tests/it/copy_direct_selection.rs +++ b/crates/engine/tests/it/copy_direct_selection.rs @@ -192,7 +192,7 @@ fn pen_tool_drawing_copies_whole_path_not_single_anchor() { } #[test] -fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { +fn cut_adjacent_anchors_takes_them_out_of_the_source() { let mut s = Session::new(); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let r = rect(&mut s, 10.0, 10.0, 100.0, 50.0); @@ -207,15 +207,93 @@ fn cut_adjacent_anchors_clipboard_plus_remaining_equals_original() { assert_eq!(clip_path.subpaths[0].segment_count(), 1); assert_eq!(clip_path.subpaths[0].anchors.len(), 2); - // Remaining source path must have the remaining 3 segments (segments 0, 2, 3), not 1. + // The source keeps anchors 3 and 0 and the one segment between them. let src_node = s.doc().unwrap().doc.node(r).unwrap(); let src_path = src_node.path_data().unwrap(); - assert_eq!(src_path.subpaths[0].segment_count(), 3); - assert_eq!(src_path.subpaths[0].anchors.len(), 4); + assert_eq!(src_path.subpaths[0].segment_count(), 1); + assert_eq!(src_path.subpaths[0].anchors.len(), 2); assert!(!src_path.subpaths[0].closed); +} - // Sum of segments in clipboard + remaining = 1 + 3 = 4 (the original 4 segments). - assert_eq!(clip_path.subpaths[0].segment_count() + src_path.subpaths[0].segment_count(), 4); +/// Every contour left in the document after `command` runs on anchors `picked` of a four-anchor +/// open path, as `[x, y]` lists. With `compound`, the path is a member of a compound path. +fn contours_after(command: &str, picked: Value, compound: bool) -> Vec> { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 200, "height": 200})); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap()); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 60, "y": 20})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 100, "y": 80})); + run(&mut s, "path.appendAnchor", json!({"id": id.0, "x": 150, "y": 80})); + if compound { + let other = rect(&mut s, 10.0, 100.0, 40.0, 40.0); + run(&mut s, "select.set", json!({"ids": [id.0, other.0]})); + run(&mut s, "object.compoundPath.make", json!({})); + } + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": picked, "mode": "set"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + contours(&s) +} + +/// Every contour in the document, as `[x, y]` lists. +fn contours(s: &Session) -> Vec> { + let mut left = vec![]; + for layer in &s.doc().unwrap().doc.layers { + layer.walk(&mut |n| { + if let Some(path) = n.path_data() { + left.extend(path.subpaths.iter().map(|sp| sp.anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>())); + } + }); + } + left +} + +#[test] +fn cut_end_anchors_leaves_the_source_as_clear_does() { + for compound in [false, true] { + let picked = || json!([[0, 0], [0, 1]]); + let left = contours_after("edit.cut", picked(), compound); + assert_eq!(left, contours_after("edit.clear", picked(), compound), "compound: {compound}"); + assert_eq!(left[0], [[100.0, 80.0], [150.0, 80.0]], "compound: {compound}"); + } +} + +#[test] +fn cut_interior_anchors_leaves_the_source_as_clear_does() { + let picked = || json!([[0, 1], [0, 2]]); + let left = contours_after("edit.cut", picked(), true); + assert_eq!(left, contours_after("edit.clear", picked(), true)); + assert!(left.iter().flatten().all(|p| p[0] != 60.0 && p[0] != 100.0), "the picked anchors are gone: {left:?}"); +} + +#[test] +fn cut_one_end_anchor_of_a_compound_member_shortens_it() { + let left = contours_after("edit.cut", json!([[0, 3]]), true); + assert_eq!(left[0], [[10.0, 20.0], [60.0, 20.0], [100.0, 80.0]]); +} + +#[test] +fn cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does() { + let after = |command: &str| { + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 400, "height": 400})); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 10, "y": 20}]}))["id"].as_u64().unwrap()); + let contour = |y: i32| json!({"anchors": [{"x": 10, "y": y}, {"x": 60, "y": y}, {"x": 110, "y": y}], "closed": false}); + run(&mut s, "path.setAnchors", json!({"id": id.0, "subpaths": [contour(20), contour(120)]})); + run(&mut s, "select.anchors", json!({"id": id.0, "anchors": [[0, 0]], "mode": "set"})); + run(&mut s, "select.segments", json!({"id": id.0, "segments": [[1, 1]], "mode": "add"})); + run(&mut s, command, json!({})); + check_session(&s).unwrap(); + s + }; + let cut = after("edit.cut"); + assert_eq!(contours(&cut), contours(&after("edit.clear"))); + assert!(contours(&cut).contains(&vec![[10.0, 20.0], [60.0, 20.0], [110.0, 20.0]]), "the picked anchor stays: {:?}", contours(&cut)); + // The clipboard holds what left the source: the picked segment alone. + assert_eq!(cut.clipboard.nodes.len(), 1); + let clip = cut.clipboard.nodes[0].path_data().unwrap(); + assert_eq!(clip.subpaths.len(), 1); + assert_eq!(clip.subpaths[0].anchors.iter().map(|a| [a.p.x, a.p.y]).collect::>(), [[60.0, 120.0], [110.0, 120.0]]); } #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index fb59612c6..e55f41eaf 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -578,6 +578,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. Regression tests: `cut_*_anchors_*` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From 965471d0da3ad01b2db9ba2f9cc423dfe723fe72 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:57:48 -0800 Subject: [PATCH 2/6] Docs: Cut's progress line states the segment rule for mixed selections --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e09a51774..8fb376813 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,7 +579,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. -- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. Regression tests: `cut_*_anchors_*` in `crates/engine/tests/it/copy_direct_selection.rs`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and leaves its picked anchors in place, on every contour, as Clear does. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. From 0aeb8e8792d3414ba93edc6e312590b1ae2138cc Mon Sep 17 00:00:00 2001 From: jmverges Date: Tue, 10 Nov 2026 02:07:37 +0000 Subject: [PATCH 3/6] .ai import: images in a spot ink's Separation space keep their ink (#6350) An image whose color space XC sets to one ink (n [c m y k /DeviceCMYK (name) /Separation] XC) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: its spot swatch, a process plate's CMYK color, or Registration. Its alpha channel becomes its opacity mask, a reduced large image's too (a colorized image reduced to fit the limits no longer keeps its alpha in its pixels). 8-bit and 1-bit samples, binary or in hex comment lines. A device space and the ink None change nothing. Co-Authored-By: Claude Opus 5.5 --- crates/eps/src/import/ai/mesh.rs | 2 +- crates/eps/src/import/ai/paint.rs | 31 ++++++ crates/eps/src/import/ai/read/art.rs | 61 +++++++++--- crates/eps/src/import/ai/read/directives.rs | 8 +- crates/eps/src/import/ai/read/ops.rs | 8 ++ crates/eps/src/import/ai/read/types.rs | 5 +- crates/eps/src/import/ai/tests.rs | 102 ++++++++++++++++++++ docs/file-format-parity.md | 6 +- 8 files changed, 206 insertions(+), 17 deletions(-) diff --git a/crates/eps/src/import/ai/mesh.rs b/crates/eps/src/import/ai/mesh.rs index 3937045db..4f667fbe4 100644 --- a/crates/eps/src/import/ai/mesh.rs +++ b/crates/eps/src/import/ai/mesh.rs @@ -133,7 +133,7 @@ impl Space { } /// A named colour or ink's full colour: `[c m y k /DeviceCMYK (name) /kind]` or `[r g b /DeviceRGB …]`. -fn ink(a: &[V]) -> Option { +pub(super) fn ink(a: &[V]) -> Option { let at = a.iter().position(|v| matches!(v, V::Name(_)))?; let nums: Vec = a.get(..at)?.iter().map(|v| v.num().filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 1.0) as f32)).collect::>()?; match (a.get(at).and_then(V::text).as_deref(), nums.as_slice()) { diff --git a/crates/eps/src/import/ai/paint.rs b/crates/eps/src/import/ai/paint.rs index a25f2b82a..5f39c3b97 100644 --- a/crates/eps/src/import/ai/paint.rs +++ b/crates/eps/src/import/ai/paint.rs @@ -72,6 +72,37 @@ pub(super) fn color_op_in(op: &str, vals: &[V], names: &mut Names) -> Option<(Pa } } +/// `n [c m y k /DeviceCMYK (name) /Separation] XC` (or `[r g b /DeviceRGB …]`) before an image: its +/// samples are one ink's tints. The ink at 100 % as a paint, with the named colour that makes its +/// spot swatch (none for Registration). `None` for a device space (`/DeviceGray XC`), the ink +/// `None`, which paints nothing, or another form. +pub(super) fn separation_ink(vals: &[V], names: &mut Names) -> Option<(Paint, Option)> { + let a = vals.iter().rev().find_map(|v| if let V::Arr(a) = v { Some(a) } else { None })?; + let [.., name @ V::Str(_), V::Name(kind)] = a.as_slice() else { return None }; + if kind.trim_start_matches('/') != "Separation" { + return None; + } + let color = super::mesh::ink(a)?; + let name = names.text(name)?; + if name == "None" { + return None; + } + if is_registration(&name) { + let paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + return Some((paint, None)); + } + // A process plate is its CMYK colour, not a spot ink: no swatch of its name. + if let Some(plate) = ["Cyan", "Magenta", "Yellow", "Black"].iter().position(|p| *p == name) { + let mut cmyk = [0.0; 4]; + if let Some(v) = cmyk.get_mut(plate) { + *v = 1.0; + } + return Some((Paint::solid(Color::cmyk(cmyk[0], cmyk[1], cmyk[2], cmyk[3])), None)); + } + let paint = Paint::Solid { color, swatch: Some(name.clone()), tint: 1.0 }; + Some((paint, Some(Named { name, color, tint: 1.0, spot: true }))) +} + fn is_registration(name: &str) -> bool { matches!(name, vectorcraft_color::swatch::REGISTRATION | "Registration" | "/All" | "All" | "/[Registration]") } diff --git a/crates/eps/src/import/ai/read/art.rs b/crates/eps/src/import/ai/read/art.rs index 7d2565172..3d859bf74 100644 --- a/crates/eps/src/import/ai/read/art.rs +++ b/crates/eps/src/import/ai/read/art.rs @@ -472,10 +472,34 @@ impl<'a> Reader<'a> { return Ok(()); }; let opacity = opacity.filter(|_| alpha_len > 0); + // An image in one ink's Separation space (`XC`): its samples are the ink's tints, the most + // at 100 % (255, or a set bit). It comes in as a grayscale image of them, black at 100 %, + // that the ink colorizes, as a Separation image opens from a PDF; its alpha channel + // becomes its opacity mask. + let ink = if channels == 1 && !image_mask { self.raster.as_mut().and_then(|r| r.ink.take()) } else { None }; + let inverted; + let samples = if ink.is_some() { + inverted = samples.iter().map(|b| !b).collect::>(); + inverted.as_slice() + } else { + samples + }; + let ink = ink.map(|(paint, named)| { + // Its colour counts toward the document's colour model, as a fill's does (Registration + // says nothing of it). + let registration = matches!(&paint, Paint::Solid { swatch: Some(s), .. } if s == vectorcraft_color::swatch::REGISTRATION); + match paint.color().filter(|_| !registration) { + Some(vectorcraft_color::Color::Rgb { .. }) => self.rgb_colors += 1, + Some(vectorcraft_color::Color::Cmyk { .. }) => self.cmyk_colors += 1, + _ => {} + } + self.spots.extend(named); + paint + }); let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none(); if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) { - // An image mask keeps the color that colorizes it. - let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint()); + // An image mask keeps the color that colorizes it, an ink image its ink. + let colorize = if image_mask && channels == 1 && *bits == 1.0 { Some(self.mask_paint()) } else { ink }; return self.large_image(samples, opacity, shape, k, &m, colorize); } // CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK @@ -487,7 +511,11 @@ impl<'a> Reader<'a> { return self.add_raster(blob, opacity, &m, (w as u32, h as u32), None); } if image_mask && channels == 1 && *bits == 1.0 { - return self.colorized_raster(samples, row, opacity, &m, (w as u32, h as u32)); + let paint = self.mask_paint(); + return self.colorized_raster(samples, (row, 1), opacity, &m, (w as u32, h as u32), paint); + } + if let Some(paint) = ink { + return self.colorized_raster(samples, (row, *bits as u8), opacity, &m, (w as u32, h as u32), paint); } let mut rgba = Vec::with_capacity((w * h * 4) as usize); for y in 0..h as usize { @@ -526,15 +554,24 @@ impl<'a> Reader<'a> { self.add_raster(ImageBlob::new("image/png", png), None, &m, (w as u32, h as u32), None) } - /// A 1-bit gray image whose ImageMask is 1 (`samples`, `row` bytes a row): a grayscale image - /// colorized with the fill color. Its 0 bits, which open black with ImageMask 0, take that color, - /// and its 1 bits are transparent. A fill that isn't a solid color colorizes it black. - fn colorized_raster(&mut self, samples: &[u8], row: usize, alpha: Option<&[u8]>, m: &[f64], (w, h): (u32, u32)) -> Result<(), String> { + /// A one-channel image (`samples` of `bits` bits, `row` bytes a row) as a grayscale image + /// colorized with `paint`: a 1-bit gray image whose ImageMask is 1, with the fill color (its 0 + /// bits, which open black with ImageMask 0, take that color, and its 1 bits are transparent; a + /// fill that isn't a solid color colorizes it black), or an ink's tints, inverted, with the ink. + fn colorized_raster( + &mut self, + samples: &[u8], + (row, bits): (usize, u8), + alpha: Option<&[u8]>, + m: &[f64], + (w, h): (u32, u32), + paint: Paint, + ) -> Result<(), String> { let mut gray = Vec::with_capacity(w as usize * h as usize); for y in 0..h as usize { let line = samples.get(y * row..(y + 1) * row).unwrap_or_default(); for x in 0..w as usize { - let v = line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255); + let v = if bits == 1 { line.get(x / 8).map_or(0, |b| ((b >> (7 - x % 8)) & 1) * 255) } else { line.get(x).copied().unwrap_or(0) }; gray.push(v); } } @@ -544,7 +581,6 @@ impl<'a> Reader<'a> { self.image_left_out("an image VectorCraft can't read was left out"); return Ok(()); } - let paint = self.mask_paint(); self.add_raster(ImageBlob::new("image/png", png), alpha, m, (w, h), Some(paint)) } @@ -625,12 +661,15 @@ impl<'a> Reader<'a> { return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize); } let cmyk = s.channels == 4; - let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk); + // A colorized image's alpha channel becomes its opacity mask, as at its full size: a + // grayscale image whose pixels have alpha takes only a spot color, black or white. + let inline = !cmyk && colorize.is_none(); + let (pixels, mask) = raster::reduce(samples, opacity, s, k, inline); let blob = if cmyk { Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i)) } else { use image::ImageEncoder as _; - let colour = match (s.channels, opacity.is_some()) { + let colour = match (s.channels, inline && opacity.is_some()) { (1, false) => image::ExtendedColorType::L8, (1, true) => image::ExtendedColorType::La8, (_, false) => image::ExtendedColorType::Rgb8, diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index e1fbe0d4d..f7df4f35d 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -181,9 +181,13 @@ impl<'a> Reader<'a> { } else if line.starts_with("AI5_EndRaster") { // A file `XG` named in this raster object is no later one's. self.image_link = None; - if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() { + if let Some(Raster { operands: Some(vals), mut samples, half, max, ink, .. }) = self.raster.take() { end_hex(&mut samples, half, max); - return self.image_from(vals, &samples, true); + // The image's ink (`XC`) stays for `image_from` to read. + self.raster = Some(Raster { ink, ..Raster::default() }); + let read = self.image_from(vals, &samples, true); + self.raster = None; + return read; } } else if line.starts_with("AI5_BeginPlace") { // One that is still open (no `~` ended it) isn't read. diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index f584f26c2..6c84eff23 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -361,6 +361,14 @@ impl<'a> Reader<'a> { r.space = vals.iter().rev().find_map(V::text); } } + // `n [… (name) /Separation] XC`: the image's samples are that ink's tints. A device + // space (`/DeviceGray XC` after a linked image's file name) changes nothing read here. + "XC" => { + let ink = paint::separation_ink(&vals, &mut self.names); + if let Some(r) = self.raster.as_mut() { + r.ink = ink; + } + } // Placed files. // `[a b c d tx ty] llx lly urx ury (path) `` ` ``, three more numbers before the path in // newer files: the version 7 form has no `%AI5_BeginPlace` before it. Before the art, diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index b07270ef8..473a07079 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -279,6 +279,9 @@ impl BrushArtColors { #[derive(Debug, Default)] pub(super) struct Raster { pub(super) space: Option, + /// The ink of an image in a Separation space (`XC`): its paint at 100 % and the named colour + /// for its swatch (`paint::separation_ink`). + pub(super) ink: Option<(Paint, Option)>, /// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to /// `%AI5_EndRaster`, the form the version 7 specification documents (§8). pub(super) operands: Option>, @@ -370,8 +373,6 @@ pub(super) const IGNORED: &[&str] = &[ "As", "Xd", "Xr", - // `/DeviceGray XC` after a linked image's file name (`XG`): its colour space. - "XC", "Xh", "XH", "Bc", diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index 2d6aa9967..19cd6fa27 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -2755,6 +2755,108 @@ fn image_masks_are_colorized_with_the_fill() { assert_eq!(pixels(&doc).0.get_pixel(0, 0).0, [255, 0, 0, 255]); } +/// A gray image `w` × 1 (`bits` bits a pixel, `alpha` channels, data `data`) whose colour space is +/// set by `xc` (the operands of `XC`) instead of `/DeviceGray XN`. +fn ink_image(xc: &str, bits: u32, alpha: u32, w: u32, data: &[u8]) -> Vec { + let mut out = image("[ 1 0 0 1 0 100 ]", (w, 1), ("DeviceGray", 1, alpha), data, data.len() + 3); + // In place, as bytes: the samples aren't text. + let mut swap = |from: &[u8], to: &[u8]| { + let at = out.windows(from.len()).position(|s| s == from).unwrap(); + out.splice(at..at + from.len(), to.iter().copied()); + }; + swap(b"/DeviceGray XN\n", format!("{xc} XC\n").as_bytes()); + swap(format!(" 8 1 {alpha} 0 1 0 ").as_bytes(), format!(" {bits} 1 {alpha} 0 1 0 ").as_bytes()); + out +} + +/// An image in one ink's Separation space (`n [… (name) /Separation] XC`): its samples are the +/// ink's tints (255 at 100 %), so it opens as a grayscale image of them, black at 100 %, colorized +/// by the ink's spot swatch; its alpha channel becomes its opacity mask. +#[test] +fn separation_images_are_colorized_with_their_ink() { + let black = "1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]"; + let r = read(&ink_image(black, 8, 0, 3, &[0, 128, 255])).unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), tint }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), *tint), ("Black Ink", 1.0)); + assert_eq!(color.to_rgba8(1.0), Color::cmyk(0.0, 0.0, 0.0, 1.0).to_rgba8(1.0)); + assert!(r.doc.swatch("Black Ink").is_some_and(|s| s.spot), "a spot swatch for the ink"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha)); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0], "no ink is white, 100 % black"); + + // An RGB alternate gives the ink's colour; an alpha channel becomes the opacity mask. + let white = "1 [1 1 1 /DeviceRGB (White Ink) /Separation]"; + let r = read(&ink_image(white, 8, 1, 2, &[255, 255, 255, 0])).unwrap(); + let a = art(&r.doc); + let Some(Paint::Solid { color, swatch: Some(swatch), .. }) = a[0].extras().colorize.as_deref() else { panic!("{:?}", a[0].extras().colorize) }; + assert_eq!((swatch.as_str(), color.to_rgba8(1.0)), ("White Ink", Color::rgb(1.0, 1.0, 1.0).to_rgba8(1.0))); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + + // 1 bit a pixel: a set bit is the ink at 100 % (101). + let r = read(&ink_image(black, 1, 0, 3, b"\xa0")).unwrap(); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [0, 255, 0]); + + // A device space, and the ink None (which paints nothing), leave the image as it was. + for xc in ["/DeviceGray", "1 [0 0 0 1 /DeviceCMYK (None) /Separation]"] { + let doc = read(&ink_image(xc, 8, 0, 2, &[0, 200])).unwrap().doc; + assert!(art(&doc)[0].extras().colorize.is_none(), "{xc}"); + assert_eq!(pixels(&doc).0.get_pixel(1, 0).0, [200, 200, 200, 255], "{xc}"); + } +} + +/// A process plate's Separation colorizes in the plate's CMYK colour, with no swatch of its name; +/// Registration in the Registration colour. +#[test] +fn separation_images_of_process_plates_and_registration() { + let r = read(&ink_image("1 [0 0 0 1 /DeviceCMYK (Black) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + assert_eq!(art(&r.doc)[0].extras().colorize.as_deref(), Some(&Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)))); + assert!(r.doc.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate"); + let r = read(&ink_image("1 [1 1 1 1 /DeviceCMYK (All) /Separation]", 8, 0, 2, &[0, 255])).unwrap(); + let swatch = art(&r.doc)[0].extras().colorize.as_deref().and_then(|p| if let Paint::Solid { swatch, .. } = p { swatch.clone() } else { None }); + assert_eq!(swatch.as_deref(), Some(vectorcraft_color::swatch::REGISTRATION)); +} + +/// The version 7 form, with samples in hex comment lines after `XI`, keeps the image's ink. +#[test] +fn a_separation_image_with_hex_comment_lines_keeps_its_ink() { + let s = "%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n\ + 1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation] XC\n[ 1 0 0 1 0 100 ] 3 1 0 Xh\n\ + [ 1 0 0 1 0 100 ] 0 0 3 1 3 1 8 1 0 0 0 0 XI\n%0080FF\n%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n"; + let mut data = stream("").into_bytes(); + let at = data.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap(); + data.splice(at..at, s.bytes()); + let r = read(&data).unwrap(); + let a = art(&r.doc); + assert!( + matches!(a[0].extras().colorize.as_deref(), Some(Paint::Solid { swatch: Some(s), .. }) if s == "Black Ink"), + "{:?}", + a[0].extras().colorize + ); + let img = pixels(&r.doc).0; + assert_eq!((0..3).map(|x| img.get_pixel(x, 0).0[0]).collect::>(), [255, 127, 0]); +} + +/// A Separation image reduced to fit the limits keeps its ink, and its alpha channel becomes its +/// opacity mask, as at its full size. +#[test] +fn a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask() { + let samples = [255u8; 16].iter().chain([0u8; 16].iter()).copied().collect::>(); + let data = ink_image("1 [0 0 0 1 /DeviceCMYK (Black Ink) /Separation]", 8, 1, 16, &samples); + let r = super::with_image_limits(4, 1 << 31, 512 << 20, || read(&data).unwrap()); + assert!(r.warnings.iter().any(|w| w.contains("was reduced")), "{:?}", r.warnings); + let a = art(&r.doc); + assert!(a[0].extras().colorize.is_some()); + assert!(a[0].extras().mask.is_some(), "the alpha channel as an opacity mask"); + let NodeKind::Image(im) = &a[0].kind else { panic!("{:?}", a[0].kind) }; + assert!(r.doc.images[&im.key].gray_source().is_some_and(|g| !g.alpha), "no alpha in the pixels"); +} + #[test] fn images_with_alpha_channels() { // RGB samples, then the alpha channel (not counted in the data's size). diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a04c8926e..1f911f6e5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (`.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -223,6 +223,9 @@ private editing data. VectorCraft: A 1-bit gray image whose `XI` or `XF` has ImageMask 1 comes in colorized by the fill color set before it, or by black when that fill is not a solid color. VectorCraft's own rule is that the pixels that open black with ImageMask 0 take the color and the others are transparent. + An image whose color space `XC` sets to one ink (`n [… (name) /Separation] XC`) comes in as a grayscale image of + its tints (black at 100 %) colorized by the ink (its spot swatch, or a process plate's CMYK color), its alpha channel + as its opacity mask (#6350). - Estimate: **~65%** for real exchange with Illustrator users (open: ~75%; save: PDF-compatible, or legacy version 7 with layers and groups; not yet tested by users who hand files back). Closing the open-side issues: 10–20 h with users' files. @@ -324,6 +327,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors, | Date | Change | Summary | |---|---|---| +| 2026-11-09 | minor | `.ai` import: an image whose color space `XC` sets to one ink (`n [c m y k /DeviceCMYK (name) /Separation] XC`, or an RGB alternate) opened as a gray RGB image: its samples, the ink's tints (255 at 100 %), were read as gray levels, so the tints drew inverted and the ink was lost. It now opens as a grayscale image of the tints, black at 100 %, colorized by the ink at 100 %: a spot swatch, a process plate's CMYK color (no swatch of its name), or Registration; the ink's color counts toward the document's color mode as a fill's does. Its alpha channel becomes its opacity mask, a reduced large image's too: a colorized image reduced to fit the limits (an image mask included) now keeps its alpha as an opacity mask rather than in its pixels, as at its full size. 8-bit and 1-bit samples, binary or in hex comment lines. A device space (`/DeviceGray XC` after a linked image's file name) and the ink `None` change nothing, as before; linked images (`XF`) keep their placeholder. Tests `import::ai::tests::separation_images_are_colorized_with_their_ink`, `separation_images_of_process_plates_and_registration`, `a_separation_image_with_hex_comment_lines_keeps_its_ink`, `a_reduced_separation_image_keeps_its_ink_and_its_alpha_as_a_mask` (#6350) | | 2026-11-09 | minor | PDF import: an image in a Separation color space (one ink, ISO 32000-1 §8.6.6.4) opens as a grayscale image of its tints (white where it lays no ink, black at 100 %, its Decode array applied; 1, 2, 4, 8 and 16 bits a sample, or one gray channel from its JPEG, CCITT, JBIG2 or JPEG 2000 decoder) colorized by its ink at 100 %: a spot ink's swatch, a process plate's CMYK color, or Registration. It used to open as an RGB image, losing the ink, and Fill and Recolor Artwork didn't reach its color. Its soft mask or mask becomes its opacity mask, as a CMYK image's does. The ink is found by the image's color space (kind, colorant name and tint transform), once per image. As with any colorized image, where it lays no ink the art under it shows, where the RGB image was opaque white. The ink `None`, which paints nothing, an ink whose color can't be told, an image inside an alpha soft mask, inline images and DeviceN images open as before; samples that can't be read (data cut short, a decoder's output of another layout) open in RGB with a warning, and the ink gets no swatch. Tests `colorize::a_separation_image_opens_colorized_by_its_ink`, `colorize::a_separation_image_reads_its_samples_and_decode_array`, `colorize::a_separation_image_of_a_process_plate_or_of_no_ink`, `colorize::a_separation_image_keeps_its_soft_mask`, `colorize::a_separation_image_drawn_twice_is_colorized_twice`, `colorize::a_separation_jpeg_opens_colorized_by_its_ink`, `colorize::a_separation_image_that_cant_be_read_stays_rgb` (#6314) | | 2026-11-09 | minor | PDF import: an image mask (`/ImageMask true`, ISO 32000-1 §8.9.6.2) painted in a solid color opens as a grayscale image of what it paints (black, the rest white) colorized by that color, as the `.ai` reader opens a 1-bit image mask (#5790). It used to open as an RGB image with the color in its pixels: Fill and Recolor Artwork didn't reach the color, a spot color lost its ink, and the same mask painted again in another color took the first color, in an uncolored pattern's cell too. Its color now counts toward the document's color mode, as a fill's does. A spot color is found by its color space, as a fill's is when the page's walk names no ink: two spaces of other inks with the same alternate and tint transform can't be told apart, and the color comes in as RGB. Inside an alpha soft mask, or painted with a pattern, a mask opens as before. Tests `colorize::an_image_mask_opens_colorized_by_its_spot_color`, `colorize::an_image_mask_drawn_in_two_colors_keeps_both`, `colorize::an_uncolored_pattern_drawing_an_image_mask_keeps_each_color` (#6313) | | 2026-11-09 | minor | PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged. Synthetic evidence: `crates/pdf/src/tests/actual_text.rs`. | From bed632cc651586368e75329944d24fafdbee3bb7 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 17:01:10 -0800 Subject: [PATCH 4/6] Transform: butt-capped lines measure to their end points with Use Preview Bounds With Use Preview Bounds on, the visual box of a butt-capped stroke on an open path grew by half the stroke weight past each end of the path, where a butt cap paints nothing. A 2 pt vertical line set to H: 10 mm in the Transform panel drew about 9.3 mm long. The box of an open subpath with butt caps no longer reaches past its ends. A straight segment's box comes from the four corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Bevel joins, and miter joins the limit bevels, are measured by the stroked corners, and round joins by the arc they paint outside the turn, instead of half the weight all round. Round and projecting caps, arrowheads, closed paths and miter tips measure as before. Two cases can still measure larger than the stroke paints: a curve that bends more tightly than half the weight, and a butt-dashed path whose first or last dash is a gap, which is measured to the path's ends. A horizontal or vertical butt line now measures one stroke weight less along its length in W or H, and Align places it by its end points. A slanted line's box shrinks by less: the stroke's corners still reach past the end points across the line. object.setBounds measures as drawn, after each scale it tries, a butt-capped open path with a segment at a slant, symbol instances' art included. --- crates/doc/src/reach.rs | 364 ++++++++++++++++++++--- crates/effects/src/stroke/tests_reach.rs | 22 ++ crates/engine/src/cmd/object/bounds.rs | 13 +- crates/engine/src/tests/previewbounds.rs | 71 +++++ crates/engine/tests/plugin_import.rs | 2 +- docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 7 files changed, 434 insertions(+), 45 deletions(-) diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 0c4ef497f..4b5f1d435 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,10 +1,10 @@ //! Where a path's strokes paint, from the path's geometry without building their outlines: the //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes -//! up to the miter limit, projecting caps and arrowheads. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; -use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; +use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest}; use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2}; use crate::appearance::HEAD_REACH; @@ -101,6 +101,58 @@ fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize, w } +/// Does the curvature of `seg` stay below one over `side` all along it? A curve's first derivative +/// is a mix of its three control differences, and its second a mix of their two differences, which +/// bound the curvature from above. Then the edges of a stroke reaching `side` from it do not fold +/// back, and those edges reach farthest at the ends of `seg` or where its tangent is square to an +/// axis. +fn gentle(seg: &PathSeg, side: f64) -> bool { + let c = seg.to_cubic(); + let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2].map(|v| v * 3.0); + let along = unit(d[0] + d[1] + d[2]); + let slowest = d.iter().map(|v| v.dot(along)).fold(f64::INFINITY, f64::min); + let bend = 2.0 * (d[1] - d[0]).hypot().max((d[2] - d[1]).hypot()); + slowest > 0.0 && side * bend < slowest * slowest +} + +/// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`: +/// the corners at the ends and the points across the stroke where the tangent is square to an axis. +/// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending +/// that much is grown by `side` on every side. +fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect { + if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) { + if depth == 0 { + return ParamCurveExtrema::bounding_box(seg).inflate(side, side); + } + return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1)); + } + let across = |t: f64| { + let (p, tn) = (seg.eval(t), tangent(seg, t)); + let n = Vec2::new(-tn.y, tn.x) * side; + [p + n, p - n] + }; + let [a, b] = across(0.0); + seg.extrema().into_iter().chain([1.0]).flat_map(across).fold(Rect::from_points(a, b), |r, p| r.union_pt(p)) +} + +/// The box of the arc a round join paints `side` from `at`, where the path turns from tangent +/// `t_in` to `t_out`: the arc runs on the outer side of the turn, and its box comes from its ends +/// and the points on it square to an axis. A joint where the path turns back on itself is given +/// the whole disc's box, and one where it does not turn paints no arc (`None`). +fn round_join_box(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option { + let (t_in, t_out) = (unit(t_in), unit(t_out)); + let turn = t_in.cross(t_out); + if turn.abs() < 1e-12 { + return (t_in.dot(t_out) < 0.0).then(|| Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + let s = turn.signum(); + let outer = |t: Vec2| Vec2::new(t.y, -t.x) * s; + let (a, b) = (outer(t_in), outer(t_out)); + let axes = [Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(0.0, -1.0)]; + let arc = Rect::from_points(at + a * side, at + b * side); + Some(axes.into_iter().filter(|d| a.cross(*d) * s >= 0.0 && d.cross(b) * s >= 0.0).fold(arc, |r, d| r.union_pt(at + d * side))) +} + /// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it? fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { let n = pts.len(); @@ -115,39 +167,81 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { impl StrokeLayer { /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, - /// or an alignment that depends on whether the path is closed? Else it reaches evenly round it. + /// an alignment that depends on whether the path is closed, or butt caps, which stop at the + /// ends of an open path? Else it reaches evenly round it. fn needs_shape(&self) -> bool { self.join == LineJoin::Miter - || self.cap == LineCap::Square + || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown - /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of + /// The box this stroke paints along `segs`, the segments with length of an open subpath, when + /// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each + /// joint between segments paints ([`round_join_box`]), a shallow one included. + /// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips + /// are measured apart. A segment in the middle is measured only when its own box grown by + /// `side`, which holds everything it paints, is not inside the box so far. + fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option { + let (first, last) = (segs.first()?, segs.last()?); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| { + if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) } + }); + if self.join != LineJoin::Round { + return Some(body); + } + Some(joints(segs, false).fold(body, |b, (at, t_in, t_out)| round_join_box(at, t_in, t_out, side).map_or(b, |j| b.union(j)))) + } + + /// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt + /// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its + /// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of /// projecting caps and the reach of the arrowheads round their end points. - pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { + fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option, measure_tips: bool) -> Rect { let els = bp.elements(); - let mut out = bounds; + let subs = subpaths(bp); + let mut out = tips.map_or(bounds, |t| bounds.union(t)); let mut side_max: f64 = 0.0; + // Every subpath reaches evenly unless one is open and butt-capped; only then is each + // subpath's own box needed. + let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1); + let mut even = even_only.then_some(bounds); // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); - for (r, closed) in subpaths(bp) { + for (r, closed) in subs { let side = self.side_reach(closed); - side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + let butt = self.cap == LineCap::Butt && !closed; + if !butt { + side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); + } if side <= 0.0 { continue; } + let square = self.cap == LineCap::Square && !closed; + let miters = measure_tips && self.join == LineJoin::Miter; + if !(butt || square || miters || !even_only) { + continue; + } let segs = drawn(&els[r]); - if self.join == LineJoin::Miter { + if miters { for (at, t_in, t_out) in joints(&segs, closed) { if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { out = out.union_pt(tip); } } } - if self.cap == LineCap::Square && !closed { + if butt { + if let Some(body) = self.butt_body(&segs, side) { + out = out.union(body); + } + continue; + } + if !even_only { + even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); + } + if square { for (at, dir) in ends(&segs).into_iter().flatten() { let n = Vec2::new(-dir.y, dir.x) * side; out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); @@ -158,7 +252,15 @@ impl StrokeLayer { let r = self.head_reach(end); out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); } - out.union(bounds.inflate(side_max, side_max)) + even.map_or(out, |e| out.union(e.inflate(side_max, side_max))) + } + + /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown + /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of + /// projecting caps and the reach of the arrowheads round their end points. An open subpath + /// with butt caps does not reach past its ends ([`Self::butt_body`]). + pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { + self.reach_along(bp, bounds, None, true) } /// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a @@ -176,30 +278,7 @@ impl StrokeLayer { /// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured /// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does. pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option) -> Rect { - let els = bp.elements(); - let mut out = tips.map_or(bounds, |t| bounds.union(t)); - let mut side_max: f64 = 0.0; - // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. - let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); - for (r, closed) in subpaths(bp) { - let side = self.side_reach(closed); - side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); - if side <= 0.0 { - continue; - } - if self.cap == LineCap::Square && !closed { - let segs = drawn(&els[r]); - for (at, dir) in ends(&segs).into_iter().flatten() { - let n = Vec2::new(-dir.y, dir.x) * side; - out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n); - } - } - } - for (at, _, end) in head_ends(self, bp) { - let r = self.head_reach(end); - out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r))); - } - out.union(bounds.inflate(side_max, side_max)) + self.reach_along(bp, bounds, tips, false) } /// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and @@ -314,7 +393,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { @@ -325,6 +404,30 @@ impl Appearance { }) } + /// Does this appearance have a visible butt-capped stroke? + pub fn has_butt_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.cap == LineCap::Butt) + } + + /// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with + /// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The + /// visual box's margin at the stroke's corners there depends on their direction, which a scale + /// out of proportion changes. + pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_butt_stroke() { + return false; + } + let slanted = |v: Vec2| { + let d = unit(a * v.to_point() - a * Point::ZERO); + (d.x * d.y).abs() > 1e-9 + }; + let els = bp.elements(); + subpaths(bp) + .into_iter() + .filter(|(_, closed)| !closed) + .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) + } + /// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within /// `tol` of `p` ([`StrokeLayer::hits`])? pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool { @@ -332,6 +435,31 @@ impl Appearance { } } +/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// before it answers yes without looking further. +pub const SLANT_INSTANCES: usize = 4096; + +impl crate::Node { + /// Does a visible butt-capped stroke of this object, of an object in it or of an object in a + /// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment + /// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it + /// answers yes. + pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) + } + + /// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into. + fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { + if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind { + let Some(l) = left.get().checked_sub(1) else { return true }; + left.set(l); + return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false); + } + (self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left))) + } +} + #[cfg(test)] mod tests { use vectorcraft_color::{Color, Paint}; @@ -378,7 +506,7 @@ mod tests { let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b)); assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}"); // A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside. - assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}"); + assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == 0.0, "{big:?}"); // The start has no head. assert_eq!(at(300.0).head_reach(false), 0.0); } @@ -405,6 +533,164 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + /// An open path through `points`. + fn polyline(points: &[(f64, f64)]) -> BezPath { + let mut bp = BezPath::new(); + for (i, p) in points.iter().enumerate() { + if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) } + } + bp + } + + #[test] + fn butt_caps_stop_at_the_ends_of_an_open_path() { + let butt = |f: fn(&mut StrokeLayer)| { + stroke(2.0, |s| { + s.cap = LineCap::Butt; + f(s); + }) + }; + // A vertical line reaches sideways only. + let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]); + assert_eq!(butt(|_| {}).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + // A 3-4-5 diagonal: its corners reach 0.8 pt across x and 0.6 pt across y. + let d = polyline(&[(0.0, 0.0), (30.0, 40.0)]); + let b = butt(|_| {}).bounds_on(&d, d.bounding_box()); + for (got, want) in [(b.x0, -0.8), (b.y0, -0.6), (b.x1, 30.8), (b.y1, 40.6)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // The dashes of a butt stroke stop inside the line as well, and an open path ignores the + // alignment. + let dashed = butt(|s| s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false })); + assert_eq!(dashed.bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + assert_eq!(butt(|s| s.align = StrokeAlign::Inside).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0)); + // A round cap reaches half the weight past each end, a square one the corners. + let round = butt(|s| s.cap = LineCap::Round).bounds_on(&v, v.bounding_box()); + assert_eq!(round, Rect::new(49.0, 9.0, 51.0, 61.0)); + let square = butt(|s| s.cap = LineCap::Square).bounds_on(&v, v.bounding_box()); + assert_eq!(square, Rect::new(49.0, 9.0, 51.0, 61.0)); + // Two strokes union. + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let wide = stroke(10.0, |s| s.cap = LineCap::Round); + ap.items.push(crate::AppearanceItem::Stroke(wide)); + let vb = v.bounding_box(); + assert_eq!(ap.stroked_bounds(vb, || v.clone()), Rect::new(45.0, 5.0, 55.0, 65.0)); + } + + #[test] + fn a_butt_paths_joins_and_curves_stay_inside_the_box() { + // A corner in the middle: the round join reaches a full half weight straight down, outside + // the turn, the ends do not, and a miter reaches its tip. + let zig = polyline(&[(0.0, 0.0), (20.0, 20.0), (40.0, 0.0)]); + let round = stroke(4.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Round; + }) + .bounds_on(&zig, zig.bounding_box()); + assert!((round.y1 - 22.0).abs() < 1e-9, "the round join's reach: {round:?}"); + assert!((round.x0 + 2.0_f64.sqrt()).abs() < 1e-9 && (round.x1 - 40.0 - 2.0_f64.sqrt()).abs() < 1e-9, "the ends' corners: {round:?}"); + // A round join paints its arc on the outer side of a turn too shallow to count as a + // corner, and nothing where the path goes straight on. + let round_butt = stroke(4.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Round; + }); + let shallow = polyline(&[(0.0, 0.25), (50.0, 0.0), (100.0, 0.25)]); + let b = round_butt.bounds_on(&shallow, shallow.bounding_box()); + assert!((b.y0 + 2.0).abs() < 1e-12, "{b:?}"); + let straight = polyline(&[(0.0, 0.0), (0.1, 0.0), (100.0, 0.0)]); + assert_eq!(round_butt.bounds_on(&straight, straight.bounding_box()), Rect::new(0.0, -2.0, 100.0, 2.0)); + let miter = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&zig, zig.bounding_box()); + assert!((miter.y1 - (20.0 + 2.0 * 2.0_f64.sqrt())).abs() < 1e-9, "the miter tip: {miter:?}"); + // An arc over the top whose ends leave the page straight down: the top reaches half the + // weight past the path, and the bottom stops at the ends. + let mut arc = BezPath::new(); + arc.move_to((0.0, 50.0)); + arc.curve_to((0.0, 22.4), (22.4, 0.0), (50.0, 0.0)); + arc.curve_to((77.6, 0.0), (100.0, 22.4), (100.0, 50.0)); + let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&arc, arc.bounding_box()); + assert!((b.y0 + 2.0).abs() < 1e-9 && (b.x0 + 2.0).abs() < 1e-9 && (b.x1 - 102.0).abs() < 1e-9, "{b:?}"); + assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() { + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let id = vectorcraft_geom::Affine::IDENTITY; + assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); + // Square ends, slanted segments in the middle. + let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]); + assert!(ap.has_slanted_butt_stroke(&apex, id)); + // A rotation slants a vertical line; a round cap is never slanted. + let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + ap.stroke_mut().unwrap().cap = LineCap::Round; + assert!(!ap.has_slanted_butt_stroke(&apex, id)); + } + + #[test] + fn the_slant_check_looks_into_at_most_4096_symbol_instances() { + // S0 is a vertical butt line. Each of S1 to S12 holds two instances of the symbol before + // it, so an instance of S12 holds 8,191 instances and none of them is slanted. Past 4,096 + // the check answers yes. + let mut d = Document::new(400.0, 400.0); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); + ap.stroke_mut().unwrap().cap = LineCap::Butt; + let line = Node::path(d.alloc_id(), PathData::from_bezpath(&polyline(&[(0.0, 0.0), (0.0, 10.0)])), ap); + d.symbols.push(crate::Symbol { dynamic: false, name: "S0".into(), art: std::sync::Arc::new(line) }); + for k in 1..=12 { + let mut instance = |dx: f64| { + let xf = vectorcraft_geom::Affine::translate((dx, 0.0)); + std::sync::Arc::new(Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf })) + }; + let pair = vec![instance(0.0), instance(20.0)]; + let art = Node::group(d.alloc_id(), pair); + d.symbols.push(crate::Symbol { dynamic: false, name: format!("S{k}"), art: std::sync::Arc::new(art) }); + } + let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let symbols = d.symbol_boxes(); + assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY)); + assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured()); + // One instance of S1 holds three instances, all looked into. + let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); + } + + #[test] + fn a_compound_path_measures_each_of_its_subpaths_by_its_own_ends() { + // A closed square at the top and an open line below it: only the square reaches past its + // own edges. + let mut bp = BezPath::new(); + bp.extend(square().elements().iter().copied()); + bp.move_to((50.0, 150.0)); + bp.line_to((50.0, 200.0)); + let st = stroke(4.0, |s| s.cap = LineCap::Butt); + assert_eq!(st.bounds_on(&bp, bp.bounding_box()), Rect::new(-2.0, -2.0, 102.0, 200.0)); + } + + #[test] + fn a_middle_segment_can_set_the_box_of_a_butt_path() { + // The flat middle segment reaches a full half weight above the path, and the ends' corners + // reach 10/51 of it below. + let hat = polyline(&[(0.0, 50.0), (10.0, 0.0), (20.0, 0.0), (30.0, 50.0)]); + let b = stroke(2.0, |s| { + s.cap = LineCap::Butt; + s.join = LineJoin::Bevel; + }) + .bounds_on(&hat, hat.bounding_box()); + let corner = 10.0 / (10.0_f64 * 10.0 + 50.0 * 50.0).sqrt(); + assert!((b.y0 + 1.0).abs() < 1e-9 && (b.y1 - 50.0 - corner).abs() < 1e-9, "{b:?}"); + } + + #[test] + fn bounds_with_tips_measures_a_butt_line_as_bounds_on_does() { + let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]); + let st = stroke(2.0, |s| s.cap = LineCap::Butt); + assert_eq!(st.bounds_with_tips(&v, v.bounding_box(), None), Rect::new(49.0, 10.0, 51.0, 60.0)); + } + #[test] fn clicks_hit_outside_strokes_on_their_side_only() { let sq = square(); diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 859d8b1c0..29a48343e 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -56,6 +56,28 @@ fn check(bp: &BezPath, st: &StrokeLayer, what: &str) { assert!(checked > 0, "{what}: no samples"); } +#[test] +fn a_butt_capped_open_path_is_measured_where_it_paints() { + let mut vertical = BezPath::new(); + vertical.move_to((50.0, 10.0)); + vertical.line_to((50.0, 90.0)); + let mut diagonal = BezPath::new(); + diagonal.move_to((10.0, 10.0)); + diagonal.line_to((40.0, 50.0)); + let mut arc = BezPath::new(); + arc.move_to((10.0, 50.0)); + arc.curve_to((10.0, 22.4), (32.4, 0.0), (60.0, 0.0)); + let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + stroke.cap = LineCap::Butt; + for (name, bp) in [("vertical", vertical), ("diagonal", diagonal), ("arc", arc)] { + let painted = outline_region(&PathData::from_bezpath(&bp), FillRule::NonZero, &stroke).to_bezpath().bounding_box(); + let bounds = stroke.bounds_on(&bp, bp.bounding_box()); + for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] { + assert!((got - want).abs() < 0.05, "{name}: measured {bounds:?}, paints {painted:?}"); + } + } +} + #[test] fn bounds_and_clicks_cover_what_every_stroke_paints() { let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; diff --git a/crates/engine/src/cmd/object/bounds.rs b/crates/engine/src/cmd/object/bounds.rs index 9d4795235..df2c1ffd1 100644 --- a/crates/engine/src/cmd/object/bounds.rs +++ b/crates/engine/src/cmd/object/bounds.rs @@ -125,15 +125,20 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec, least: f64, anchor // The scales and the point above can miss the size or the reference point when the margins of // the objects differ, the members of groups and the objects in symbols' art included, and with // strokes that scale, when the sizes are out of proportion. The objects are then measured one - // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects are - // measured as drawn after each scale tried instead ([`refit_drawn`]). + // by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects, and + // objects with a butt-capped stroke along a segment at a slant, whose margins change with the + // segment's direction, are measured as drawn after each scale tried instead ([`refit_drawn`]). let symbols = d.symbol_boxes(); - let drawn = || ids.iter().filter_map(|id| d.node(*id)).any(|n| effects::preview::draws_geometry(&symbols, n)); + let to_local = frame.to_doc().inverse(); + let drawn = || { + ids.iter() + .filter_map(|id| d.node(*id)) + .any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local)) + }; let solved = if !(s.prefs.use_preview_bounds && w >= 0.0 && h >= 0.0) { None } else if transforms_objects(p) && !bool_or(p, "typeAreas", false) && drawn() { let options = scaling_options(s, p); - let to_local = frame.to_doc().inverse(); let measure = |sx: f64, sy: f64| { let xf = frame.conjugate(about(rp, Affine::scale_non_uniform(sx, sy))); let sc = if Scaling::factor(xf).is_some() { options } else { Scaling::default() }; diff --git a/crates/engine/src/tests/previewbounds.rs b/crates/engine/src/tests/previewbounds.rs index c9bad6e60..773b2b784 100644 --- a/crates/engine/src/tests/previewbounds.rs +++ b/crates/engine/src/tests/previewbounds.rs @@ -618,6 +618,77 @@ fn set_bounds_leaves_the_height_of_lines_with_no_height() { assert!(close(geometric(&s, ids[1]), Rect::new(8.3, 100.0, 30.0, 100.0)), "{:?}", geometric(&s, ids[1])); } +/// A line with a 2 pt stroke and butt caps, selected, with Use Preview Bounds on. +fn butt_line(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> NodeId { + let id = NodeId(run(s, "shape.line", json!({"x1": from.0, "y1": from.1, "x2": to.0, "y2": to.1}))["id"].as_u64().unwrap()); + run(s, "stroke.set", json!({"weight": 2, "cap": "butt"})); + preview(s, true); + run(s, "select.set", json!({"ids": [id.0]})); + id +} + +#[test] +fn set_bounds_gives_a_butt_line_the_whole_height() { + // A butt cap ends at the anchor, so 10 mm (28.3465 pt) is the length of the line itself. + let mut s = session(); + let id = butt_line(&mut s, (100.0, 100.0), (100.0, 140.0)); + assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 140.0)), "{:?}", s.transform_bounds(&[id])); + run(&mut s, "object.setBounds", json!({"height": 28.3465, "reference": 0, "strokes": false})); + assert!(close(geometric(&s, id), Rect::new(100.0, 100.0, 100.0, 128.3465)), "{:?}", geometric(&s, id)); + assert!(close(s.transform_bounds(&[id]).unwrap(), Rect::new(99.0, 100.0, 101.0, 128.3465)), "{:?}", s.transform_bounds(&[id])); +} + +#[test] +fn set_bounds_gives_a_diagonal_butt_line_the_size_out_of_proportion() { + // The stroke's corners follow the line's slope, which a scale out of proportion changes. + let mut s = session(); + let id = butt_line(&mut s, (100.0, 100.0), (130.0, 140.0)); + let before = s.transform_bounds(&[id]).unwrap(); + // The stroke's corners reach 0.8 pt across x and 0.6 pt across y at this 3-4-5 slope. + assert!(close(before, Rect::new(99.2, 99.4, 130.8, 140.6)), "{before:?}"); + run(&mut s, "object.setBounds", json!({"height": 100, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.height() - 100.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn set_bounds_gives_a_butt_path_with_slanted_corners_the_size_out_of_proportion() { + // Square ends, but the bevel join at the apex sets the top edge by the slanted segments' + // corners, which a scale out of proportion moves. + let mut s = session(); + let anchors = json!([{"x": 100, "y": 150}, {"x": 100, "y": 140}, {"x": 110, "y": 100}, {"x": 120, "y": 140}, {"x": 120, "y": 150}]); + let id = NodeId(run(&mut s, "path.create", json!({"anchors": anchors}))["id"].as_u64().unwrap()); + run(&mut s, "stroke.set", json!({"weight": 2, "cap": "butt", "join": "bevel"})); + preview(&mut s, true); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 60, "height": 20, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 60.0).abs() < 1e-6 && (v.height() - 20.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn set_bounds_gives_a_symbol_instance_of_a_diagonal_butt_line_the_size_out_of_proportion() { + let mut s = session(); + butt_line(&mut s, (100.0, 100.0), (130.0, 140.0)); + let id = NodeId(run(&mut s, "symbol.new", json!({"name": "Line"}))["id"].as_u64().unwrap()); + run(&mut s, "select.set", json!({"ids": [id.0]})); + run(&mut s, "object.setBounds", json!({"width": 20, "height": 100, "reference": 0, "strokes": false})); + let v = s.transform_bounds(&[id]).unwrap(); + assert!((v.width() - 20.0).abs() < 1e-6 && (v.height() - 100.0).abs() < 1e-6, "{v:?}"); +} + +#[test] +fn align_places_a_vertical_butt_line_by_its_end_points() { + // With Use Preview Bounds on, a butt line's ends line up with the top of a rectangle without + // a stroke. + let mut s = session(); + rect(&mut s, 20.0, 50.0, 0.0); + let id = butt_line(&mut s, (200.0, 160.0), (200.0, 190.0)); + run(&mut s, "select.all", json!({})); + run(&mut s, "object.align", json!({"vertical": "top"})); + assert!(close(geometric(&s, id), Rect::new(200.0, 100.0, 200.0, 130.0)), "{:?}", geometric(&s, id)); +} + /// The rectangles of [`thin_and_thick`] in a group, the thick one in a group of its own inside it /// when `nested`, selected with an unstroked rectangle from x 100 to 110 when `plain`. Returns the /// selected objects. diff --git a/crates/engine/tests/plugin_import.rs b/crates/engine/tests/plugin_import.rs index 71f21e10d..b780c115c 100644 --- a/crates/engine/tests/plugin_import.rs +++ b/crates/engine/tests/plugin_import.rs @@ -145,7 +145,7 @@ fn a_file_an_importer_reads_opens_as_editable_art() { let stroked = r#"{"objects":[{"type":"path","path":{"subpaths":[{"closed":false,"anchors":[{"p":[0,-4000000]},{"p":[50,-4000000]}]}]},"strokes":[{"paint":{"type":"solid","color":{"model":"rgb","r":0,"g":0,"b":0}},"width":10}]}]}"#; open(&mut s, "Stroked.tso", stroked.as_bytes(), json!({})).unwrap(); let (x0, y0, x1, y1) = artboard(&s); - assert!(x0 < 0.0 && x1 > 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); + assert!(x0 == 0.0 && x1 == 50.0 && y0 == -4_000_000.0 && y1 > y0 + 1.0, "{:?}", artboard(&s)); s.execute("select.all", &json!({})).unwrap(); s.execute("object.move", &json!({"dx": 0, "dy": 5})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 8f605eee0..d4a26e995 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3188,7 +3188,11 @@ the `…Open` rings…) and marks (`Bar`, `DoubleBar`, `DotOnBar`, `Slash`, `Dou Visual bounds (Fit to Selected Art, Rasterize, exporting all art) and clicks on the canvas take in the whole stroke as drawn: arrowheads, inside/outside alignment (an outside stroke is hit outside the path, an inside one inside it), the width profile's width where you click, projecting caps and miter spikes up to the miter -limit. A rectangle's right-angle miters stay within half the weight of its edges. +limit. A rectangle's right-angle miters stay within half the weight of its edges. A butt-capped stroke on an open +path does not reach past the path's ends: a straight segment's box comes from the four corners of its stroked +rectangle. Past an end, only a round or projecting cap or an arrowhead adds to the box. A curve that bends more +tightly than half the stroke weight can measure a little larger than it paints, and a butt-dashed path whose first or +last dash is a gap is measured to the path's ends. A symbol instance is measured by its symbol's art, placed by the instance's transform. The bounds in `document.inspect` and `document.node`, the Align commands, `object.setBounds`, the bounding box, clicks, marquees, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6346f0020..721ed42af 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1044,6 +1044,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html), [Pathfinder panel](https://helpx.adobe.com/illustrator/using/pathfinder-panel-overview.html)): the Pathfinder panel menu's Trap… (`object.pathfinder.trap`) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add, where two objects' visible areas of different solid colours meet, a strip of the darker one's area along the edge, filled with the lighter colour at the Tint Reduction percentage and set to overprint (Reverse Traps: the darker colour spreads instead, but not a rich black); Thickness 0.01–5000 pt across vertical edges, Height/Width scales it across horizontal ones, Traps with Process Color paints a swatch colour's trap with its CMYK equivalent; the effect also has Precision and Remove Redundant Points. The panel's Trap is one undo step and groups the traps above the topmost selected object, selected; with no adjoining edges of different colours it says so and changes nothing. Tests: `crates/pathops/src/trap.rs`, `crates/effects/src/trap.rs`, `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `crates/engine/src/tests/trap.rs`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. - Compound shapes ([Create compound shapes with Pathfinder](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/create-compound-shapes-with-pathfinder.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html)): the Pathfinder panel menu's Make Compound Shape (every member in Add) and Alt-clicking a Shape Mode (every member in that mode) make the two or more selected paths, compound paths, groups, compound shapes or type a live compound shape (`object.compoundShape.make`): a group whose members each keep a shape mode and stay editable, drawn, printed and exported (SVG, PDF and the rest through the baked document) as the area the modes make from the backmost member up, open paths closed, painted with its own appearance, taken from the topmost member not subtracting. With members selected (Direct Selection, Layers panel, isolation mode) a Shape Mode button sets theirs (`object.compoundShape.setMode`) and is lit for the mode they share; paint given to a member goes to the compound shape. Release Compound Shape gives the members back as they were (`object.compoundShape.release`); Expand Compound Shape and the panel's Expand button turn it into the path or compound path of its area (`object.compoundShape.expand`). Shape Modes and Pathfinders take a compound shape as its area. Saved files keep it (`compound_shape`, `shape_mode`; an unknown mode reads as Add). Blends, envelopes and objects with a warp or another geometry effect are members as they draw (a blend's steps, an envelope's distorted art, the warped outline), and the area follows an edit of the blend, envelope or warp; Release gives them back as they were. Tests: `crates/engine/src/tests/compound_shape.rs`, `group::tests::compound_shapes_draw_the_area_their_shape_modes_make`, `group::tests::nested_compound_shapes_take_part_as_their_area`, `panels::pathfinder::tests::compound_shape_items_follow_the_selection`, the `CompoundShape` op of the property-test strategies. Not yet: the question marks on mixed member modes, hit-testing by the area (a click hits the members). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is measured by its glyph outlines. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). + - A butt cap ends a stroke square at the end of an open path, and a projecting cap extends half the weight past it. VectorCraft's visual box of a butt-capped stroke on an open path does not grow past the path's ends: a straight segment's box comes from the corners of its stroked rectangle, and a curve's box comes from the stroke at its ends and where its tangent is square to an axis. Round and projecting caps and arrowheads still reach past an end. A curve that bends more tightly than half the weight can measure a little larger than it paints, and a butt-dashed path whose first or last dash is a gap is measured to the path's ends. Public reference: [Apply stroke on an object](https://helpx.adobe.com/illustrator/using/stroke-object.html). - With Use Preview Bounds on, the bounding box, the Transform panel, Align, Distribute, Distribute Spacing and `object.setBounds` measure each object after its geometry effects. A path is measured by its effected shape, and a fill or stroke with geometry effects of its own is measured by the shape it paints. Type, images, live objects and symbol instances are measured by their reshaped art, and a symbol instance is also measured by its reshaped art when its symbol's art has geometry effects. A group or layer with geometry or Pathfinder effects, or with a fill or stroke of its own that has geometry effects or paints members with geometry effects, is measured by its evaluated art. A blend, envelope or repeat whose objects have geometry effects is measured by its evaluated art, with those objects measured after their effects. A 100 pt square without a stroke, with Offset Path 10, measures 120 pt, and type with Outline Object aligns and sizes by its glyphs. Raster effects are not measured. Public reference: [Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html) (archived copy). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Cap is on by default with continuous curved vector rims; shading uses vector gradients automatically, with sharp profile corners, uniform caps and round seam joins. One light and approximate visibility remain ([revolve.md](revolve.md)). - Effect › Path › Outline Object has no options. Type that carries it is drawn, exported (PDF and SVG as paths, with no font) and expanded as its glyph outlines while it stays editable, and the effects after it reshape those outlines. Paths and images draw as before. With Use Preview Bounds on, type is still measured by its layout box. Public reference: [Summary of effects](https://web.archive.org/web/20210901011839/https://helpx.adobe.com/illustrator/using/summary-effects.html) (archived copy). From db56690f481d48a667ba06492f56ec3a87511186 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:19:24 -0800 Subject: [PATCH 5/6] MCP: docs list every JSON Schema keyword in a command's schema The Parameter schemas section of docs/mcp.md now names every keyword ParamSchema::to_json writes: $schema, description and deprecated at the root, items for lists, prefixItems, items: false and minItems for a point, anyOf and minItems for a color, and description, default and deprecated on a parameter. --- docs/mcp.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index 66459255a..b12a00365 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1387,9 +1387,13 @@ vectorcraft-cli run --cmd file.new --params '{"width":600,"height":200}' --cmd Some commands have a machine-readable parameter schema: at present `view.capsLock`, `view.saved.new`, `view.saved.edit`, `view.saved.list`, `view.transparencyGrid` and `view.snapToGlyph.options`. The engine's `engine.commands` list gives such a command a `schema` field: an object schema in the shape of JSON Schema 2020-12 -(https://json-schema.org/draft/2020-12), with `type`, `properties`, `required`, `additionalProperties: false`, -`minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges, `enum`, and `default` for a default the engine fills in. VectorCraft's own -fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, +(https://json-schema.org/draft/2020-12). Its root has `$schema` (the 2020-12 dialect), `description` when the +command has a summary, and `deprecated: true` when the command is deprecated. The schema uses `type`, `properties`, `required` and +`additionalProperties: false` for objects; `minimum`, `maximum`, `exclusiveMinimum` and `exclusiveMaximum` for ranges; +`enum` for a list of names; `items` for the values of a list; `prefixItems`, `items: false` and `minItems: 2` for a +point; `anyOf` and `minItems: 3` for a color, which is a string, a list of at least three numbers or an object; +`description` for a parameter's doc text; `default` for a default the engine fills in; and `deprecated: true` for a +deprecated parameter. VectorCraft's own fields are `x-since` (the API level that added the command or the parameter), `x-deprecatedSince`, `x-lenient`, `x-unit`, `x-kind` (`id`, `color` or `point`), `x-default` (a default the command works out, such as `toggle`), `x-cost`, `x-writes` (`nothing`, `session`, `preferences` or `document`) and `x-returns`. A parameter that reads null as left out is an `anyOf` with `{"type": "null"}`. Null params read as `{}`, so a schema with no required parameter has From 1847f40fbea14f06e79e6184b0c0dd9f92bef335 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 18:28:16 -0800 Subject: [PATCH 6/6] Docs: Cut's progress line says anchors left without a segment go too --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8fb376813..fc7324408 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -579,7 +579,7 @@ since 2026-10-01. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. -- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and leaves its picked anchors in place, on every contour, as Clear does. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. +- **Cut of picked anchors (#6361):** Edit › Cut removes direct-selected anchors from their path and from a compound path's member, with the segments attached to them, as Clear does. Picked segments still lose only those segments. When a path also has a picked segment, Cut removes and copies only its picked segments and ignores its picked anchors, on every contour, as Clear does; an anchor left without a surviving segment is removed with them. Regression tests: `cut_*_anchors_*` and `cut_of_an_anchor_with_a_segment_on_another_contour_leaves_the_source_as_clear_does` in `crates/engine/tests/it/copy_direct_selection.rs`. - **Escape in Selection tool drags:** Escape during a Selection tool move or Alt-drag copy, a bounding-box scale or rotation, a marquee, or a corner widget, pie widget or bracket drag cancels it: the art returns to its state at the press, no copy is left, no undo step is added, the release does nothing, and the tool stays chosen. This is VectorCraft's own behavior. Regression tests: `escape_cancels_*` in `select/tests.rs` and `pie.rs`, and `escape_cancels_a_selection_tool_move_copy_scale_or_rotation` in `tests/selectescape.rs`. - **Global Edit: Shift-click leaves a match out:** while Global Edit is on, a Shift-click with the Selection tool on a highlighted match, or on an object inside a matched group, leaves that match out (`select.globalEditExclude`): it is no longer highlighted and later edits leave it as it is; the object the edit started from stays, and Global Edit ends when fewer than two matches are left ([Update assets quickly with Global Edit](https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html)). Regression tests: `a_shift_click_*`, `an_object_left_out_*`, `a_group_left_out_*` and `leaving_out_all_but_one_*` in `crates/engine/src/tests/globaledit.rs`. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`.