From 318e6b07b7653782f10373ff5efde393ee84a1c5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 13:13:08 -0700 Subject: [PATCH 01/37] Docs: G15 and the parity doc cite budget runs and slowness reports since 2026-10-11 The "Performance unverified" bullet in docs/target-app-parity.md said that the budgets had not been run on an idle machine since 2026-10-01, that the last loaded run missed the 16 ms frame budget at 50k paths, and that no user had reported slowness in mainstream work beyond #575 and #758. On 2026-10-11 #1155 recorded idle-machine runs, made before #1173 merged, and runs of #1173's change met every budget at 50k paths (load average 4 to 7) and at 25k paths. #1097 (zoom and pan in 0.9.0) and #1731 (a drag that stutters as it starts) are open. G15 in docs/gaps.md called the 25k-path run the last one, but on 2026-10-13 a comment on #1172 reported runs of 0.10.0 on Linux with 4 logical CPUs that missed all three render budgets at 20k paths. The bullet now gives the 25k-path run and the runs in #1172, and lists #1097 and #1731 with #575 and #758 as open user reports of slowness. G15 no longer calls the 25k-path run the last one and adds the runs in #1172. --- docs/gaps.md | 5 +++-- docs/target-app-parity.md | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 90800266d..c157e658f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -218,8 +218,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G15 Performance budgets and a GPU renderer - **Improved (#1257):** Craquelure, Mosaic Tiles and Stained Glass reuse squared distances during nearest-cell searches, with the original distance ordering retained near rounding/tie boundaries. A brute-force boundary regression and full-viewport pixel comparisons agree with the old result. `texture_drag_perf` reproduces a moved CMYK rounded rectangle at 190.07% and DPI 1.5; timings from a busy development machine are supporting evidence, with idle Windows drag QA still pending. -- **Missing:** an idle-machine run of `vectorcraft-cli perf` at 50k paths (last: 2026-10-11 at 25k paths, every - budget met, 400% zoom 12.3 ms against 16 ms, measured), a spatial index for zoomed-in frames, dirty-region +- **Missing:** an idle-machine run of `vectorcraft-cli perf` at 50k paths (2026-10-11 at 25k paths, every budget + met, 400% zoom 12.3 ms against 16 ms, measured; runs of 0.10.0 on Linux with 4 logical CPUs, reported in #1172 + on 2026-10-13, missed all three render budgets at 20k paths), a spatial index for zoomed-in frames, dirty-region rendering, the GPU backend spike (M5). Large-selection edits, cached live effects and Pathfinder Trim, Merge, Crop and Outline landed in #1173. - **Not redrawn:** the canvas keeps drawing these objects as they were: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f35f3cfc8..be8a18010 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -168,9 +168,11 @@ The discounts, with the evidence: - **Stability on real machines (×0.92).** The code never panics by design, and the main platform (macOS) starts reliably, but 26 issues mention a crash, freeze or failed launch (8 open: #858, #964 fixed the day of this review, #620, #818, #905 on Windows 7, #834). There is no field record and no real-world file corpus yet. -- **Performance unverified.** Budgets (`vectorcraft-cli perf`) haven't been run on an idle machine since - 2026-10-01; the last loaded run missed the 16 ms frame budget at 50k paths. Not discounted separately: no - user has reported slowness in mainstream work beyond #575 (Linux AppImage) and #758 (`.ai` open time). +- **Performance unverified.** Budgets (`vectorcraft-cli perf`) were all met at 25k paths on 2026-10-11; runs of + 0.10.0 on Linux with 4 logical CPUs, reported in #1172 on 2026-10-13, missed all three render budgets at 20k + paths. Performance is not discounted separately. Open user reports of slowness in mainstream work include #575 + (Linux AppImage), #758 (`.ai` open time), #1097 (zoom and pan in 0.9.0) and #1731 (a drag that stutters as it + starts). - **Missing subsystems** a minority need daily (3D, the Effect Gallery, CJK vertical composition, scripting) pull the full number below the mainstream one. From 891a58eff8c2d81252850342b9eb959f4570c567 Mon Sep 17 00:00:00 2001 From: Mara Johnson Date: Tue, 13 Oct 2026 19:18:38 -0400 Subject: [PATCH 02/37] fix(snap): snap Direct Selection anchor drags to grid intersections (#1816) --- crates/engine/src/tests.rs | 20 ++++++++++++++++++++ crates/engine/src/tooling.rs | 4 +++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1ced86452..41acc0149 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1480,6 +1480,26 @@ fn direct_selection_moves_one_anchor() { assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(200.0, 100.0)); } +/// #1816: with Snap to Grid on, a dragged anchor lands on the grid's subdivisions, not on whole +/// points, even with Snap to Pixel left on too. +#[test] +fn direct_selection_snaps_anchors_to_the_grid() { + let mut s = session(); + let a = rect(&mut s, 101.0, 101.0, 100.0, 100.0); + s.execute("select.none", &json!({})).unwrap(); + let step = s.doc().unwrap().doc.grid.spacing / f64::from(s.doc().unwrap().doc.grid.subdivisions.max(1)); + let on_grid = |v: f64| (v / step - (v / step).round()).abs() < 1e-9; + let v = ViewInfo { snap_to_grid: true, snap_to_pixel: true, ..ViewInfo::default() }; + s.select_tool("directSelection", v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Down, 101.0, 101.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, 131.0, 113.0), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, 131.0, 113.0), v).unwrap(); + let p = s.doc().unwrap().doc.node(a).unwrap().path_data().unwrap().clone(); + let moved = p.subpaths[0].anchors[0].p; + assert!(on_grid(moved.x) && on_grid(moved.y), "anchor at {moved:?}, grid step {step}"); + assert_eq!(p.subpaths[0].anchors[1].p, vectorcraft_geom::Point::new(201.0, 101.0)); +} + /// Follow-up to #1254: toggling or adding anchors of a path selected as a whole starts from all /// its anchors, so toggling one deselects it alone and adding one keeps them all. #[test] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 297b008b9..dcb6b3a29 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -188,7 +188,9 @@ impl Session { guides: view.guides && !self.menu.guides_locked, snap_to_grid: view.snap_to_grid, show_bbox: view.show_bbox, - snap_to_pixel: view.snap_to_pixel, + // Snap to Grid and Snap to Pixel are exclusive, as in Illustrator; left both on, the grid + // wins instead of pixels quietly rounding every drag to whole points (#1816). + snap_to_pixel: view.snap_to_pixel && !view.snap_to_grid, snap_to_point: view.snap_to_point, corner_widgets: view.corner_widgets, fill_active: self.fill_active, From 4ef0cb76e64d1d3c54f8020a69b000183cd7f6b8 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 01:29:32 +0200 Subject: [PATCH 03/37] Transform panel: X/Y/W/H edit the anchors selected with Direct Selection (fixes #1824) --- crates/engine/src/cmd/object.rs | 31 +++++++++++++++++++++++++- crates/engine/src/tests.rs | 26 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 11 ++++++--- crates/ui-egui/src/panels/transform.rs | 23 +++++++++++++++++++ 4 files changed, 87 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 33efe7641..3fbe8314e 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -202,7 +202,7 @@ pub fn specs() -> Vec { "Set Bounds", [], None, - "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)", + "{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))", has_selection, set_bounds ), @@ -1400,6 +1400,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { fn set_bounds(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; + let st = s.doc()?; + if transforms_objects(p) + && !bool_or(p, "copy", false) + && let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds) + { + return set_anchor_bounds(s, p, ids, b); + } let d = &s.doc()?.doc; // Sizes are measured square to the bounding box, which a rotation turns with the objects: // the math below runs in its frame. @@ -1477,6 +1484,28 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false) } +/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of +/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only +/// the anchors, their handles with them, and those objects move or scale. +fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; + let rp = vectorcraft_geom::reference_point(b, refi); + let mut w = f64_or(p, "width", b.width()); + let mut h = f64_or(p, "height", b.height()); + if bool_or(p, "proportional", false) { + if p.get("width").is_some() && b.width() > 0.0 { + h = b.height() * w / b.width(); + } else if p.get("height").is_some() && b.height() > 0.0 { + w = b.width() * h / b.height(); + } + } + // A single anchor, or anchors in a line, have no size to scale on that side. + let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 }; + let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height()))); + let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y)); + apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true) +} + /// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with /// symbol instances measured without the strokes in their art /// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]). diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 41acc0149..9d87ed2fe 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -821,6 +821,32 @@ fn transforms_act_on_direct_selected_anchors() { assert_eq!(points(&s), [(25.0, 0.0), (75.0, 0.0), (100.0, 100.0), (0.0, 100.0)]); } +/// #1824: with anchors selected with Direct Selection, the Transform panel's X/Y/W/H +/// (`object.setBounds`) are the box around those anchors, and only they move or scale. +#[test] +fn set_bounds_acts_on_direct_selected_anchors() { + let mut s = session(); + let square = json!({"anchors": [{"x": 0, "y": 0}, {"x": 100, "y": 0}, {"x": 100, "y": 100}, {"x": 0, "y": 100}], "closed": true}); + let id = NodeId(s.execute("path.create", &square).unwrap()["id"].as_u64().unwrap()); + let points = |s: &Session| -> Vec<(f64, f64)> { + let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(id).unwrap().kind else { panic!("a path") }; + path.subpaths[0].anchors.iter().map(|a| (a.p.x.round(), a.p.y.round())).collect() + }; + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 1]]})).unwrap(); + s.execute("object.setBounds", &json!({"x": 130, "reference": 0})).unwrap(); + assert_eq!(points(&s), [(0.0, 0.0), (130.0, 0.0), (100.0, 100.0), (0.0, 100.0)], "only the selected anchor moves"); + s.execute("object.setBounds", &json!({"y": 20, "reference": 4})).unwrap(); + assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (100.0, 100.0), (0.0, 100.0)]); + // Two anchors: W scales their box about the reference point, the others stay. + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 2], [0, 3]]})).unwrap(); + s.execute("object.setBounds", &json!({"width": 50, "reference": 3})).unwrap(); + assert_eq!(points(&s), [(0.0, 0.0), (130.0, 20.0), (50.0, 100.0), (0.0, 100.0)]); + // The path selected whole moves as a whole. + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + s.execute("object.setBounds", &json!({"x": 10, "reference": 0})).unwrap(); + assert_eq!(points(&s), [(10.0, 0.0), (140.0, 20.0), (60.0, 100.0), (10.0, 100.0)]); +} + /// Arrange commands that leave the stacking order as it is record no undo step: Bring to Front and /// Bring Forward on the front objects, Send to Back and Send Backward on the back ones, and Send to /// Current Layer on objects already on top of the current layer. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ebc4c1236..d211c5c2d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -977,8 +977,10 @@ impl VectorcraftApp { self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts) } - /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The - /// canvas and the transform fields read it every frame: it is measured once per revision. + /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]); with + /// anchors selected with Direct Selection, the box around them square to the page, which + /// `object.setBounds` then edits (#1824). The canvas and the transform fields read it every + /// frame: it is measured once per revision. pub fn selection_box(&mut self) -> Option { let st = self.session.active()?; let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); @@ -987,7 +989,10 @@ impl VectorcraftApp { { return b; } - let b = self.session.transform_box(&st.selection.objects); + let b = match st.selection.anchor_bounds(&st.doc, self.session.prefs.use_preview_bounds) { + Some(r) => Some(vectorcraft_doc::OrientedBox::aligned(r)), + None => self.session.transform_box(&st.selection.objects), + }; self.canvas.selection_box = Some((key, b)); b } diff --git a/crates/ui-egui/src/panels/transform.rs b/crates/ui-egui/src/panels/transform.rs index a57faabb8..ae9841e78 100644 --- a/crates/ui-egui/src/panels/transform.rs +++ b/crates/ui-egui/src/panels/transform.rs @@ -486,6 +486,29 @@ mod tests { assert_eq!(selected_guides(&app), None); } + /// #1824: with an anchor selected with Direct Selection the panel shows that anchor's position, + /// and a new X moves it alone. + #[test] + fn an_anchor_selection_shows_and_moves_its_own_box() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + let square = json!({"anchors": [{"x": 20, "y": 30}, {"x": 120, "y": 30}, {"x": 120, "y": 130}, {"x": 20, "y": 130}], "closed": true}); + let id = app.run("path.create", square).unwrap()["id"].as_u64().unwrap(); + let shown = texts(&mut app, show); + assert!(shown.iter().any(|t| t == "70 pt") && shown.iter().any(|t| t == "100 pt"), "the whole path: {shown:?}"); + app.run("select.anchors", json!({"id": id, "anchors": [[0, 1]]})).unwrap(); + let shown = texts(&mut app, show); + for v in ["120 pt", "30 pt", "0 pt"] { + assert!(shown.iter().any(|t| t == v), "{v} in {shown:?}"); + } + assert!(!shown.iter().any(|t| t == "70 pt" || t == "100 pt"), "{shown:?}"); + app.run("object.setBounds", json!({"x": 150, "reference": 4})).unwrap(); + let n = first_selected(&app).unwrap(); + let Some(path) = n.path_data() else { panic!("a path") }; + let xs: Vec = path.subpaths[0].anchors.iter().map(|a| a.p.x).collect(); + assert_eq!(xs, [20.0, 150.0, 120.0, 20.0]); + } + #[test] fn artboard_fields_keep_the_reference_point() { let r = Rect::new(0.0, 0.0, 300.0, 200.0); From 61ef472e71b03334525f2424bab5b080a1d84918 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:17:14 +0700 Subject: [PATCH 04/37] object.setBounds: one helper for the asked width and height --- crates/engine/src/cmd/object.rs | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 3fbe8314e..452797f38 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -1426,15 +1426,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]]; let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; let rp = vectorcraft_geom::reference_point(v, refi); - let mut w = f64_or(p, "width", v.width()); - let mut h = f64_or(p, "height", v.height()); - if bool_or(p, "proportional", false) { - if p.get("width").is_some() && v.width() > 0.0 { - h = v.height() * w / v.width(); - } else if p.get("height").is_some() && v.height() > 0.0 { - w = v.width() * h / v.height(); - } - } + let (w, h) = wanted_size(p, v); // The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is // exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the // scales where it misses. @@ -1484,12 +1476,9 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result { apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false) } -/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of -/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only -/// the anchors, their handles with them, and those objects move or scale. -fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { - let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; - let rp = vectorcraft_geom::reference_point(b, refi); +/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or +/// with `proportional` follows the one given. +fn wanted_size(p: &Value, b: Rect) -> (f64, f64) { let mut w = f64_or(p, "width", b.width()); let mut h = f64_or(p, "height", b.height()); if bool_or(p, "proportional", false) { @@ -1499,6 +1488,16 @@ fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> R w = b.width() * h / b.height(); } } + (w, h) +} + +/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of +/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only +/// the anchors, their handles with them, and those objects move or scale. +fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec, b: Rect) -> Result { + let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize; + let rp = vectorcraft_geom::reference_point(b, refi); + let (w, h) = wanted_size(p, b); // A single anchor, or anchors in a line, have no size to scale on that side. let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 }; let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height()))); From 7aa00b41da99696ad0befb88bdd17dfaca7bacbc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:28:49 +0700 Subject: [PATCH 05/37] Fix #1718: ids at their largest value in a file no longer exhaust the counters A native file (or a pasted document) whose node, slice or asset ids or whose next-id counter sit at u64::MAX made the next allocation overflow: a panic with overflow checks, a wrapped counter handing out ids already in use without. fix_next_id now leaves ids and counters from 2^62 up out of the count, so new ids start under them; alloc_id saturates. Artboard and graphic style ids take the smallest free id when the largest is u32::MAX instead of wrapping to the reserved 0. --- crates/doc/src/lib.rs | 85 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 54af60a94..8c3ad1a8f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -775,7 +775,9 @@ impl Document { /// Allocate a fresh node id. pub fn alloc_id(&mut self) -> NodeId { let id = NodeId(self.next_id); - self.next_id += 1; + // Never reached from a counter [`Self::fix_next_id`] has seen (it stays under + // [`MAX_NEXT_ID`]); saturating so that no counter can overflow. + self.next_id = self.next_id.saturating_add(1); id } pub fn peek_next_id(&self) -> u64 { @@ -783,14 +785,22 @@ impl Document { } /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). pub fn fix_next_id(&mut self) { + // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a + // counter up there: new ids start under them and never reach them, where counting on from + // the largest would run out and hand out ids the document already uses. let mut max = 0; + let mut see = |id: u64| { + if id < MAX_NEXT_ID { + max = max.max(id); + } + }; for l in &self.layers { - l.walk(&mut |n| max = max.max(n.id.0)); + l.walk(&mut |n| see(n.id.0)); } - max = self.slices.iter().fold(max, |m, s| m.max(s.id.0)); - max = self.assets.iter().fold(max, |m, a| m.max(a.id)); - // An id of u64::MAX (a damaged file) can't overflow: ids after it are reused at worst. - self.next_id = self.next_id.max(max.saturating_add(1)); + self.slices.iter().for_each(|s| see(s.id.0)); + self.assets.iter().for_each(|a| see(a.id)); + let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); + self.next_id = counter.max(max.saturating_add(1)); } /// Find a node anywhere in the tree. @@ -1376,7 +1386,7 @@ impl Document { self.guides.extend(copies); } pub fn next_artboard_id(&self) -> u32 { - self.artboards.iter().map(|a| a.id).max().unwrap_or(0) + 1 + free_id(self.artboards.iter().map(|a| a.id)) } pub fn pattern(&self, name: &str) -> Option<&PatternDef> { self.patterns.iter().find(|p| p.name == name) @@ -1460,7 +1470,7 @@ impl Document { } /// An id no graphic style has. pub fn next_graphic_style_id(&self) -> u32 { - self.graphic_styles.iter().map(|g| g.id).max().unwrap_or(0) + 1 + free_id(self.graphic_styles.iter().map(|g| g.id)) } /// The id of graphic style `index`, assigning one if it has none yet. pub fn graphic_style_id(&mut self, index: usize) -> u32 { @@ -1499,11 +1509,29 @@ fn default_graphic_styles() -> Vec { .collect() } +/// Node, slice and asset ids are handed out below this: no document gets near it by counting, so +/// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). +const MAX_NEXT_ID: u64 = 1 << 62; + +/// An id (never 0) none of `used` has: one past the largest, or, when that is the largest there +/// is (a damaged file), the smallest one free. +fn free_id(used: impl Iterator) -> u32 { + let mut used: Vec = used.collect(); + if let Some(next) = used.iter().max().copied().unwrap_or(0).checked_add(1) { + return next; + } + used.sort_unstable(); + used.dedup(); + // Ids 1, 2, 3… as far as they are all taken: the first that isn't follows them. + let taken = used.iter().filter(|&&id| id != 0).zip(1u32..).take_while(|&(&id, n)| id == n).count(); + u32::try_from(taken).map_or(u32::MAX, |n| n.saturating_add(1)) +} + impl Document { /// Make the next id allocated at least `next`: ids handed out outside the layer tree (such /// as an importer's opacity mask and pattern art), which [`Document::fix_next_id`] doesn't see. pub fn reserve_ids(&mut self, next: u64) { - self.next_id = self.next_id.max(next); + self.next_id = self.next_id.max(next.min(MAX_NEXT_ID)); } } @@ -1628,6 +1656,45 @@ mod tests { assert_eq!(d.layer_color(l2), LAYER_COLORS[1].1); } + /// #1718: a file whose ids or counters are at their type's largest value still hands out ids + /// nothing in it uses (and never 0), instead of overflowing. + #[test] + fn exhausted_ids_in_a_file_dont_run_out() { + let (d, a, b) = doc_with_rects(); + let mut v = serde_json::to_value(&d).unwrap(); + v["next_id"] = serde_json::json!(u64::MAX); + // Object `a` gets the largest id there is. + fn renumber(v: &mut serde_json::Value, from: u64) { + match v { + serde_json::Value::Object(o) => { + if o.get("id").and_then(serde_json::Value::as_u64) == Some(from) { + o.insert("id".into(), serde_json::json!(u64::MAX)); + } + o.values_mut().for_each(|c| renumber(c, from)); + } + serde_json::Value::Array(a) => a.iter_mut().for_each(|c| renumber(c, from)), + _ => {} + } + } + renumber(&mut v["layers"], a.0); + let mut d: Document = serde_json::from_value(v).unwrap(); + assert!(d.node(NodeId(u64::MAX)).is_some(), "the file's largest id is kept"); + d.fix_next_id(); + let fresh: Vec = (0..3).map(|_| d.alloc_id()).collect(); + assert!(fresh.iter().all(|id| d.node(*id).is_none() && id.0 > b.0 && id.0 < MAX_NEXT_ID), "{fresh:?}"); + assert!(fresh[0] != fresh[1] && fresh[1] != fresh[2]); + // A counter left at the end by other means stays there rather than wrapping to 0. + d.next_id = u64::MAX; + assert_eq!((d.alloc_id(), d.peek_next_id()), (NodeId(u64::MAX), u64::MAX)); + d.reserve_ids(u64::MAX); + // Artboards and graphic styles: one past the largest, else the smallest id free. + assert_eq!(free_id([1, 2, 7].into_iter()), 8); + assert_eq!(free_id([u32::MAX, 0, 1, 2, 4].into_iter()), 3); + assert_eq!(free_id([u32::MAX].into_iter()), 1); + d.artboards[0].id = u32::MAX; + assert_eq!(d.next_artboard_id(), 1); + } + #[test] fn serde_roundtrip() { let (d, _, _) = doc_with_rects(); From 355bb5c4f6ea271377dce4ddc38e9eddd449a651 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:29:36 +0700 Subject: [PATCH 06/37] Fix #1697: a failed command.batch restores each document whole The rollback undid the batch's interaction, which holds the art, the selection and panel state only: a Save As step's new file, format and saved state stayed, and an Undo step (which ends the interaction) left the art and the history undone. The batch now keeps the documents as they were and puts them back, and settles a drag or typing in progress first, as Undo does, instead of running inside it and keeping its baseline as the batch's step. Files the steps wrote stay written. --- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/fileio/batch.rs | 41 ++++++++++++++------------- crates/engine/tests/model_based.rs | 38 +++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b439766b0..57ddb369e 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -265,7 +265,7 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result { /// under an interaction that would then preview on, or cancel to, the document from before (#1719). /// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the /// document. -fn settle_interaction(s: &mut Session) -> Result<()> { +pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> { let typing = typing_in_progress(s); s.commit_interaction()?; if typing { diff --git a/crates/engine/src/cmd/fileio/batch.rs b/crates/engine/src/cmd/fileio/batch.rs index dd836846f..99d166676 100644 --- a/crates/engine/src/cmd/fileio/batch.rs +++ b/crates/engine/src/cmd/fileio/batch.rs @@ -13,14 +13,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result { // What an error rolls back besides the documents: session-level state a step may change // (paint defaults, drawing mode, clipboard, the Untitled-N count). let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter); - let (start, active): (Vec, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active); + // A drag or typing still in progress is a step of its own, as before Undo: the batch's steps + // would otherwise run inside it and take its baseline as theirs (#1697). + if s.active().is_some() { + super::super::edit::settle_interaction(s)?; + } + // The documents as they are now, whole: what a failed step brings back. Their art, history, + // file, format and saved state share their data with the open ones until a step changes them. + let start = s.docs.clone(); + let active = s.active; s.batch_stash = Some(vec![]); let r = run_steps(s, &label, artboard, &mut cmds); let stash = s.batch_stash.take().unwrap_or_default(); let results = match r { Ok(results) => results, Err(e) => { - roll_back(s, &start, stash); + roll_back(s, start, &stash); s.active = active; (s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved; s.pending_paint = None; @@ -81,22 +89,17 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option, cmds: &mut [Va Ok(results) } -/// The documents as they were before the batch (`start`: their uids in order): the ones it opened -/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone. -fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec) { - let mut open = std::mem::take(&mut s.docs); - for &uid in start { - // The newest revision the document reached, so the restored one redraws. - let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default(); - // Its state before the batch: the first one a step closed or replaced, else the open one. - let st = match stash.iter().position(|d| d.uid == uid) { - Some(i) => Some(stash.remove(i)), - None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)), - }; - if let Some(mut st) = st { - st.undo_interaction(); - st.revision = newest.saturating_add(1); - s.docs.push(st); - } +/// The documents as they were before the batch (`start`), whatever the steps did to them: opened +/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection, +/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left +/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay +/// written. +fn roll_back(s: &mut Session, start: Vec, stash: &[DocState]) { + let open = std::mem::take(&mut s.docs); + s.docs = start; + for st in &mut s.docs { + // Past the newest revision the document reached, so the restored one redraws. + let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision); + st.revision = newest.saturating_add(1); } } diff --git a/crates/engine/tests/model_based.rs b/crates/engine/tests/model_based.rs index af91b2490..13a210324 100644 --- a/crates/engine/tests/model_based.rs +++ b/crates/engine/tests/model_based.rs @@ -121,6 +121,44 @@ fn batch_is_one_undo_step_and_rolls_back_on_error() { assert_eq!(s.doc().unwrap().doc.node_count(), 1); } +/// #1697: a failed batch brings back the whole document, not only its art: the file a Save As +/// step named and the history an Undo step moved; and a batch leaves a drag in progress its own +/// step. +#[test] +fn a_failed_batch_restores_the_file_the_history_and_a_drag_in_progress() { + let mut s = fixtures::session(); + let rect = |x: i32| json!({"command": "shape.rectangle", "params": {"x": x, "y": 0, "width": 10, "height": 10}}); + let fail = json!({"command": "no.such.command", "params": {}}); + exec(&mut s, "shape.rectangle", json!({"x": 50, "y": 0, "width": 10, "height": 10})); + let before = (doc_json(&s.doc().unwrap().doc), s.doc().unwrap().history.undo.len(), s.doc().unwrap().history.redo.len()); + let state = |s: &vectorcraft_engine::Session| { + let st = s.doc().unwrap(); + (doc_json(&st.doc), st.history.undo.len(), st.history.redo.len()) + }; + // Save As, then a failing step: the document keeps no file and stays modified. + let path = vectorcraft_testkit::temp_dir("batch-rollback").join("named.vectorcraft").to_string_lossy().to_string(); + let save = json!({"command": "file.saveAs", "params": {"path": path}}); + assert!(s.execute("command.batch", &json!({"commands": [rect(0), save, fail]})).is_err()); + assert_eq!(state(&s), before); + assert_eq!((s.doc().unwrap().path.as_deref(), s.doc().unwrap().is_dirty()), (None, true)); + // Undo, then a failing step: the art and both history stacks are as before. + let undo = json!({"command": "edit.undo", "params": {}}); + assert!(s.execute("command.batch", &json!({"commands": [undo, fail]})).is_err()); + assert_eq!(state(&s), before); + // A batch during a drag: the drag is kept as a step, the batch is the next one. + exec(&mut s, "select.all", json!({})); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 5, "dy": 0})).unwrap(); + let moved = doc_json(&s.doc().unwrap().doc); + exec(&mut s, "command.batch", json!({"commands": [rect(100)]})); + assert!(!s.in_interaction()); + assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 2); + exec(&mut s, "edit.undo", json!({})); + assert_eq!(doc_json(&s.doc().unwrap().doc), moved, "undoing the batch leaves the drag's move"); + exec(&mut s, "edit.undo", json!({})); + assert_eq!(doc_json(&s.doc().unwrap().doc), before.0); +} + #[test] fn empty_batch_is_harmless() { let mut s = fixtures::session(); From ea30e411e716fe24db0c7a0642223ae10ca1770d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:31:02 +0700 Subject: [PATCH 07/37] Fix #1725: a control request its caller timed out on is withdrawn, not run later The worker answered "timeout" after 60 s and left the request on the queue, so the app ran it whenever it next drained the queue: an edit nobody was waiting for, and a second one if the caller had retried. A request now leaves the queue once: taken by the app, or given up by its caller, whichever comes first. The timeout reply says which ("will not run" / "still running"). --- apps/vectorcraft/src/control_server.rs | 31 +++++++++++++++++++++++- crates/ui-egui/src/control.rs | 33 +++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 7 +++++- docs/control-protocol.md | 6 +++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/apps/vectorcraft/src/control_server.rs b/apps/vectorcraft/src/control_server.rs index 846ebc46f..21eacd61a 100644 --- a/apps/vectorcraft/src/control_server.rs +++ b/apps/vectorcraft/src/control_server.rs @@ -29,6 +29,21 @@ pub const MAX_CONNECTIONS: usize = 16; /// Size in bytes of the buffer that [`write_line`] writes a reply line through. const REPLY_CHUNK: usize = 64 * 1024; +/// How long a request waits for the app's reply. +const REPLY_WAIT: Duration = Duration::from_secs(60); + +/// The reply to a request the app didn't answer in [`REPLY_WAIT`]. One it had not started is +/// withdrawn, so it never runs after its caller was told it failed; one it is running can't be +/// stopped, and the reply says its outcome isn't known. +fn timed_out(ticket: &vectorcraft_ui_egui::ControlTicket) -> Value { + let error = if ticket.give_up() { + "timeout: the app did not start the request in time; it was withdrawn and will not run" + } else { + "timeout: the app is still running the request; it may yet complete" + }; + json!({"ok": false, "error": error}) +} + #[derive(Clone, Copy)] struct ConnectionTimeouts { idle: Duration, @@ -106,9 +121,10 @@ fn spawn_connection( let _slot = slot; serve_socket(stream, timeouts, |method, params| { let (req, rrx) = ControlRequest::new(method, params); + let ticket = req.ticket(); tx.send(req).ok()?; ctx.request_repaint(); - Some(rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))) + Some(rrx.recv_timeout(REPLY_WAIT).unwrap_or_else(|_| timed_out(&ticket))) }); }) } @@ -455,6 +471,19 @@ mod tests { json!({"ok": true, "result": {"layers": [{"children": paths, "id": 1, "kind": "Layer", "name": "Layer 1"}]}}) } + /// #1725: a request its caller gave up before the app took it never runs; one the app took + /// first can't be withdrawn, and the timeout says so. + #[test] + fn a_timed_out_request_is_withdrawn_unless_it_started() { + let (waiting, _rx) = ControlRequest::new("engine.execute", json!({})); + assert!(timed_out(&waiting.ticket())["error"].as_str().unwrap().contains("will not run")); + assert!(!waiting.claim(), "the app skips a withdrawn request"); + let (running, _rx) = ControlRequest::new("engine.execute", json!({})); + assert!(running.claim()); + assert!(timed_out(&running.ticket())["error"].as_str().unwrap().contains("still running")); + assert!(!running.claim(), "a request is taken once"); + } + #[test] fn requests_are_answered_in_order_with_ids() { let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ef921fdbf..4cb60e214 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -24,6 +24,8 @@ //! - `app.export {path?, format?, …document.export options}`: encoded by the engine, written through //! the host; the document keeps its path. No path → `{dataBase64, format, bytes}` (as headless) +use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::mpsc::Sender; use serde_json::{Value, json}; @@ -63,12 +65,41 @@ pub struct ControlRequest { pub method: String, pub params: Value, pub reply: Sender, + state: Arc, +} + +/// A request's state: waiting to run, taken by the app ([`ControlRequest::claim`]), or given up +/// by its caller before that ([`Ticket::give_up`]). It leaves `QUEUED` once, one way or the other. +const QUEUED: u8 = 0; +const STARTED: u8 = 1; +const GIVEN_UP: u8 = 2; + +/// The caller's side of a queued [`ControlRequest`]. +pub struct Ticket(Arc); + +impl Ticket { + /// Stop waiting for the request → `true` when it had not started, and so never runs: a caller + /// told "timeout" can then retry without the edit being applied twice, or applied later when + /// nobody expects it (#1725). `false`: the app is running it (or has); its outcome is unknown. + pub fn give_up(&self) -> bool { + self.0.compare_exchange(QUEUED, GIVEN_UP, Ordering::SeqCst, Ordering::SeqCst).is_ok() + } } impl ControlRequest { pub fn new(method: impl Into, params: Value) -> (Self, std::sync::mpsc::Receiver) { let (tx, rx) = std::sync::mpsc::channel(); - (Self { method: method.into(), params, reply: tx }, rx) + (Self { method: method.into(), params, reply: tx, state: Arc::new(AtomicU8::new(QUEUED)) }, rx) + } + + /// The caller's handle on this request, to give it up with. + pub fn ticket(&self) -> Ticket { + Ticket(self.state.clone()) + } + + /// Take the request to run it → `false` when its caller gave it up meanwhile (don't run it). + pub fn claim(&self) -> bool { + self.state.compare_exchange(QUEUED, STARTED, Ordering::SeqCst, Ordering::SeqCst).is_ok() } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d211c5c2d..e3e953cc9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -162,7 +162,7 @@ use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::file_access::{self, AutomationRoots}; use vectorcraft_engine::{Session, ViewInfo}; -pub use control::{ControlRequest, ControlResponse}; +pub use control::{ControlRequest, ControlResponse, Ticket as ControlTicket}; pub use state::{UiState, View}; pub use sysclip::{ClipboardProbeFactory, SystemClipboard}; @@ -769,6 +769,11 @@ impl VectorcraftApp { let mut handled = 0; while handled < control::MAX_CONTROL_REQUESTS_PER_FRAME { let Ok(req) = rx.try_recv() else { break }; + // Its caller stopped waiting (the app was busy or its window not drawn for too long) + // and was told it won't run: an edit applied now would come from nowhere (#1725). + if !req.claim() { + continue; + } handled += 1; let reply = req.reply.clone(); // Guarded per request: a panic must not drop the channel (taken out of `self` above). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index bbbaf648e..742f627e5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,6 +43,12 @@ and does not append a partial gesture. Clients can retry once earlier input has scheduled. These limits cover synthetic input admission, separately from socket transport and command-processing timeouts. +A request the app hasn't answered after **60 s** gets `{"ok": false, "error": "timeout: …"}`. If the +app had not started it (it was busy, or its window wasn't being drawn), the request is withdrawn +and never runs, and the error says "will not run": retrying is safe. If the app was already +running it, the error says "still running": it may yet complete, so check the document before +retrying. + ## Confining file access ```sh From 277e893e828fc500dfd26073014515948ba134d9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:32:48 +0700 Subject: [PATCH 08/37] Fix #1715: ASCII85 decoding is bounded while it decodes A `z` is four bytes for one character, so the text's length didn't bound the result, and the filter's and the interpreter's budgets only saw it once it was whole. The decoder now stops at 256 MB, the most one filter or data section may hold. --- crates/eps/src/ps.rs | 19 ++++++++++++++++--- crates/eps/src/tests.rs | 5 +++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index e00d813a4..fb27425ed 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -181,13 +181,26 @@ fn push85(s: &mut String, mut v: u32, n: usize) { } } -/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed. +/// Most bytes [`ascii85_decode`] makes: what one filter or data section may hold. +pub(crate) const MAX_ASCII85: usize = 1 << 28; + +/// ASCII85 `text` (whitespace ignored, up to `~>`) decoded; `None` when it is malformed or longer +/// than [`MAX_ASCII85`]. pub(crate) fn ascii85_decode(text: &str) -> Option> { - let mut out = Vec::with_capacity(text.len() * 4 / 5); + ascii85_decode_within(text, MAX_ASCII85) +} + +/// [`ascii85_decode`] of at most `max` bytes: `z` is four bytes for one, so the length of the +/// text doesn't bound what it becomes (#1715). +pub(crate) fn ascii85_decode_within(text: &str, max: usize) -> Option> { + let mut out = Vec::with_capacity((text.len() / 5 * 4).min(max)); let mut group = [0u8; 5]; let mut n = 0; let mut chars = text.bytes().filter(|b| !b.is_ascii_whitespace()).peekable(); while let Some(b) = chars.next() { + if out.len() > max { + return None; + } match b { b'~' => { if chars.next() != Some(b'>') { @@ -201,7 +214,7 @@ pub(crate) fn ascii85_decode(text: &str) -> Option> { let v = value85(&padded)?; out.extend(v.to_be_bytes().iter().take(n - 1)); } - return Some(out); + return (out.len() <= max).then_some(out); } b'z' if n == 0 => out.extend([0; 4]), b'!'..=b'u' => { diff --git a/crates/eps/src/tests.rs b/crates/eps/src/tests.rs index b4e5fa1c3..4cab46284 100644 --- a/crates/eps/src/tests.rs +++ b/crates/eps/src/tests.rs @@ -345,6 +345,11 @@ fn run_length_and_ascii85_round_trip() { assert_eq!(ps::ascii85(b"Man "), "9jqo^~>\n"); assert_eq!(ps::ascii85(&[0, 0, 0, 0]), "z~>\n"); assert!(ps::ascii85_decode("9jqo^").is_none(), "no end marker"); + // #1715: the result is bounded while it is made, not by the length of the text. + assert_eq!(ps::ascii85_decode_within("zz~>", 8), Some(vec![0; 8])); + assert!(ps::ascii85_decode_within("zzz~>", 8).is_none(), "three `z` are 12 bytes"); + assert!(ps::ascii85_decode_within("zz9jqo^~>", 8).is_none()); + assert!(ps::ascii85_decode_within(&format!("{}~>", "z".repeat(1 << 16)), 1 << 10).is_none()); assert!(ps::ascii85_decode("9jqo^v~>").is_none(), "not base 85"); assert_eq!(ps::num(1.0 / 3.0), "0.3333"); assert_eq!((ps::num(-0.00001), ps::num(f64::NAN), ps::num(2.5)), ("0".into(), "0".into(), "2.5".into())); From 0cb55bc0aa56f31d16fa943139db731bdafb5b7d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:31:04 +0700 Subject: [PATCH 09/37] Fix #1717: sampling and recolouring check an image's RGBA size before decoding The decoder's 512 MB limit is on the file's samples; a grey image within it is four times that once converted to RGBA for the Eyedropper or a recolour, which the web build can't allocate. The size is now read from the header and held to the allowance the importers and the renderer use. --- crates/doc/src/lib.rs | 1 + crates/doc/src/pixels.rs | 32 ++++++++++++++++++++++++++-- crates/engine/src/cmd/fileio/load.rs | 2 +- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 8c3ad1a8f..7d7a33e52 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -31,6 +31,7 @@ pub mod overprint; pub mod pattern; pub mod perspective; mod pixels; +pub use pixels::MAX_RASTER_ALLOC; pub mod placed_document; pub mod profiles; pub mod puppet; diff --git a/crates/doc/src/pixels.rs b/crates/doc/src/pixels.rs index 219b6a819..525014fc0 100644 --- a/crates/doc/src/pixels.rs +++ b/crates/doc/src/pixels.rs @@ -6,6 +6,22 @@ use std::sync::Arc; use crate::ImageBlob; +/// The most memory the decoded RGBA pixels of one image may take: the allowance the importers +/// and the renderer decode within (a 60 in sheet at 300 ppi is ~475 MB). The web app has 4 GB to +/// address and aborts when an allocation fails, so it keeps the decoder's own 512 MB. +pub const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; + +/// The pixels of encoded image `bytes` as RGBA; `None` when they can't be decoded or would take +/// more than `max` bytes as RGBA. The size is read from the header first: the decoder's own limit +/// is on the file's samples, and a grey image is a quarter of what it becomes here (#1717). +fn rgba_within(bytes: &[u8], max: u64) -> Option { + let (w, h) = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_dimensions().ok()?; + if u64::from(w) * u64::from(h) * 4 > max { + return None; + } + Some(image::load_from_memory(bytes).ok()?.to_rgba8()) +} + impl ImageBlob { /// A PNG blob of `png` bytes. pub fn png(png: Vec) -> Self { @@ -21,7 +37,7 @@ impl ImageBlob { /// pixels left alone, re-encoded as PNG. `f` runs once per distinct colour. `None` when the /// image can't be decoded or no pixel changed. pub fn map_rgb(&self, mut f: impl FnMut([u8; 3]) -> [u8; 3]) -> Option { - let mut img = image::load_from_memory(&self.bytes).ok()?.to_rgba8(); + let mut img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?; let mut memo: HashMap<[u8; 3], [u8; 3]> = HashMap::new(); let mut changed = false; for px in img.pixels_mut().filter(|p| p.0[3] > 0) { @@ -67,7 +83,7 @@ impl ImageBlob { LAST.with_borrow_mut(|last| match last { Some((k, img)) if *k == key => Some(img.clone()), _ => { - let img = Arc::new(image::load_from_memory(&self.bytes).ok()?.to_rgba8()); + let img = Arc::new(rgba_within(&self.bytes, MAX_RASTER_ALLOC)?); *last = Some((key, img.clone())); Some(img) } @@ -115,6 +131,18 @@ mod tests { image::load_from_memory(&b.bytes).unwrap().to_rgba8().pixels().map(|p| p.0).collect() } + /// #1717: the RGBA size is checked from the header, before anything is decoded: a grey image + /// the decoder's own limit lets through is four times that as RGBA. + #[test] + fn pixels_past_the_allowance_are_not_decoded() { + let grey = image::GrayImage::from_raw(4, 2, vec![7; 8]).unwrap(); + let mut png = Vec::new(); + grey.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + assert!(rgba_within(&png, 31).is_none(), "8 grey bytes are 32 as RGBA"); + assert_eq!(rgba_within(&png, 32).map(|i| i.dimensions()), Some((4, 2))); + assert!(rgba_within(b"not an image", u64::MAX).is_none()); + } + #[test] fn samples_a_pixel_or_the_average_around_it() { let b = blob(&[[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255], [9, 9, 9, 0]], 4, 1); diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 1ac08df1f..891a43070 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -419,7 +419,7 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result { /// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px) /// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps /// that default: wasm32 has 4 GB to address and aborts when an allocation fails. -pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 }; +pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC; /// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its /// header says so; the pixels aren't read): the renderer, which has the same allowance, draws From 729e5ced7fa85b514444d9cdcf64f4b0c46a12f6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:32:48 +0700 Subject: [PATCH 10/37] Fix #1714: the EPS interpreter frees arrays and dictionaries that hold themselves Composite objects are reference-counted, so `/a 1 array def a 0 a put` (or two dictionaries naming each other) outlived the import: emptying the standard dictionaries and the stacks doesn't break a cycle, least of all one the program no longer reaches. Up to the interpreter's whole memory budget could stay allocated per imported file. The interpreter now lists the arrays and dictionaries it makes (weakly, swept as they are dropped) and empties the ones still alive when it is done. --- crates/eps/src/import/interp.rs | 6 ++ crates/eps/src/import/obj.rs | 98 ++++++++++++++++++++++++++++++++- crates/eps/src/import/tests.rs | 17 ++++++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index f74b5df04..b60aac39d 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -29,6 +29,8 @@ pub(crate) const MAX_GSAVE: usize = 1000; const MAX_MEMORY: usize = 1 << 30; pub(crate) struct Interp<'a> { + /// The list of the arrays and dictionaries made since this started ([`super::obj::track`]). + made: u64, pub lex: Lexer<'a>, pub stack: Vec, pub dicts: Vec, @@ -101,6 +103,7 @@ const CATEGORIES: &str = "Category Generic Font CIDFont CMap FontSet Encoding Fo impl<'a> Interp<'a> { pub fn new(src: &'a [u8], g: GState, out: Out) -> Self { + let made = super::obj::track(); let system = new_dict(); { let mut d = system.borrow_mut(); @@ -140,6 +143,7 @@ impl<'a> Interp<'a> { system.borrow_mut().insert(Key::name(name), Obj::Dict(d.clone())); } Self { + made, lex: Lexer::new(src), stack: vec![], dicts: vec![system.clone(), global.clone(), user.clone()], @@ -205,6 +209,8 @@ impl<'a> Interp<'a> { self.dicts.clear(); self.stack.clear(); self.journal.clear(); + // What the program linked into itself, reachable or not. + super::obj::release_tracked(self.made); } // ---------- the operand stack ---------- diff --git a/crates/eps/src/import/obj.rs b/crates/eps/src/import/obj.rs index ae8f7a8e5..f540500c4 100644 --- a/crates/eps/src/import/obj.rs +++ b/crates/eps/src/import/obj.rs @@ -3,7 +3,7 @@ use std::cell::{Ref, RefCell}; use std::collections::HashMap; use std::hash::{Hash, Hasher}; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use super::data::Stream; use super::graphics::GState; @@ -17,10 +17,95 @@ pub(crate) struct DictRef(Rc>); impl DictRef { pub fn new(d: Dict) -> Self { - Self(Rc::new(RefCell::new(d))) + let d = Rc::new(RefCell::new(d)); + note(Made::Dict(Rc::downgrade(&d))); + Self(d) } } +/// An array or a dictionary a program made. +enum Made { + Array(Weak>>), + Dict(Weak>), +} + +/// The arrays and dictionaries made on this thread since [`track`], for [`release_tracked`]. +struct Tracked { + owner: u64, + made: Vec, + /// The list is cleared of dropped objects when it reaches this length. + sweep_at: usize, +} + +const SWEEP_MIN: usize = 4096; + +#[cfg(test)] +thread_local! { + /// Tests: the arrays of the list last released on this thread that were still alive after it + /// (the ones the interpreter itself still held). + pub(crate) static ARRAYS_LEFT: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +thread_local! { + static TRACKED: RefCell> = const { RefCell::new(None) }; +} + +/// Start listing the arrays and dictionaries made on this thread (an interpreter starting) → the +/// list's owner, for [`release_tracked`]. A list already there is dropped: its objects are freed +/// as before this existed, unless they hold each other. +pub(crate) fn track() -> u64 { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + let owner = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // (Nothing to list on when the thread is ending.) + let _ = TRACKED.try_with(|t| t.try_borrow_mut().map(|mut t| *t = Some(Tracked { owner, made: vec![], sweep_at: SWEEP_MIN }))); + owner +} + +fn note(m: Made) { + let _ = TRACKED.try_with(|t| { + let Ok(mut t) = t.try_borrow_mut() else { return }; + let Some(t) = t.as_mut() else { return }; + if t.made.len() >= t.sweep_at { + // Twice what is alive at most, so the list costs a program no more than its objects. + t.made.retain(|m| match m { + Made::Array(a) => a.strong_count() > 0, + Made::Dict(d) => d.strong_count() > 0, + }); + t.sweep_at = (t.made.len() * 2).max(SWEEP_MIN); + } + t.made.push(m); + }); +} + +/// Empty every array and dictionary still alive of the list `owner` started (the interpreter is +/// done with them), so those holding themselves or each other are freed too: reference counts +/// alone never free `/a 1 array def a 0 a put`, and a program can leave as much of that behind as +/// it may allocate, on every import (#1714). +pub(crate) fn release_tracked(owner: u64) { + let made = TRACKED.try_with(|t| { + let mut t = t.try_borrow_mut().ok()?; + t.take_if(|t| t.owner == owner).map(|t| t.made) + }); + let made = made.ok().flatten().unwrap_or_default(); + #[cfg(test)] + let arrays: Vec<_> = made.iter().filter_map(|m| if let Made::Array(a) = m { Some(a.clone()) } else { None }).collect(); + for m in made { + // What it holds is taken out first and dropped with nothing borrowed. + match m { + Made::Array(a) => { + let held = a.upgrade().and_then(|a| a.try_borrow_mut().ok().map(|mut values| std::mem::take(&mut *values))); + drop_held(held.unwrap_or_default().into_iter()); + } + Made::Dict(d) => { + let held = d.upgrade().and_then(|d| d.try_borrow_mut().ok().map(|mut d| std::mem::take(&mut *d))); + drop_held(held.unwrap_or_default().into_values()); + } + } + } + #[cfg(test)] + ARRAYS_LEFT.with(|n| n.set(arrays.iter().filter(|a| a.strong_count() > 0).count())); +} + impl std::ops::Deref for DictRef { type Target = Rc>; fn deref(&self) -> &Self::Target { @@ -70,6 +155,8 @@ fn drop_held(held: impl Iterator) { pub(crate) trait Held: Sized { /// Drop the values of storage nothing refers to any more. fn release(_values: Vec) {} + /// Storage of these values was made. + fn made(_storage: &Rc>>) {} } impl Held for u8 {} @@ -78,6 +165,9 @@ impl Held for Obj { fn release(values: Vec) { drop_held(values.into_iter()); } + fn made(storage: &Rc>>) { + note(Made::Array(Rc::downgrade(storage))); + } } /// A dictionary key. @@ -219,7 +309,9 @@ impl Drop for Shared { impl Shared { pub fn new(v: Vec) -> Self { - Self { len: v.len(), buf: Rc::new(RefCell::new(v)), at: 0 } + let (len, buf) = (v.len(), Rc::new(RefCell::new(v))); + T::made(&buf); + Self { len, buf, at: 0 } } pub fn len(&self) -> usize { diff --git a/crates/eps/src/import/tests.rs b/crates/eps/src/import/tests.rs index 63cf89b4c..678ae993b 100644 --- a/crates/eps/src/import/tests.rs +++ b/crates/eps/src/import/tests.rs @@ -19,6 +19,23 @@ pub(super) fn read(body: &str) -> crate::Imported { import(&eps(body)).unwrap() } +/// #1714: arrays and dictionaries a program links into themselves or each other are freed with +/// the interpreter, also the ones it no longer reaches and when it stops on an error. +#[test] +fn arrays_and_dictionaries_holding_themselves_are_freed() { + // The arrays still alive once a program's interpreter has released what it made: the ones + // the interpreter itself holds, the same for every program here. + let left = |program: &str| { + let _ = import(&eps(program)); + crate::import::obj::ARRAYS_LEFT.with(std::cell::Cell::get) + }; + let plain = left("/a 1 array def /b 1 array def a 0 b put"); + let cycles = "/a 1 array def a 0 a put /b 1 array def /c 1 array def b 0 c put c 0 b put /d 1 dict def /e 1 array def d /e e put e 0 d put 1 array dup 0 1 index put pop /a null def /b null def"; + for program in [cycles.to_string(), format!("{cycles} 1 0 div"), format!("{cycles} /p {{ p }} def")] { + assert_eq!(left(&program), plain, "{program}"); + } +} + /// The objects of the document's layer. pub(super) fn objects(d: &Document) -> Vec> { d.layers[0].children().unwrap().clone() From 0ad92e4649c13af8c20b51570b4985728c1fd569 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:37:15 +0700 Subject: [PATCH 11/37] Fix #1704: an Affinity document's pixel layers share one pixel allowance Each pixel layer was held to 64 megapixels, and the archive's limits count the bytes extracted from it; a layer whose tiles are flat colour needs none, so a small file with many of them allocated 256 MB each without end. The layers of one document now decode 512 megapixels (2 GB) at most together; those past it are left out with a note, as an oversized one is. --- crates/affinity/src/model.rs | 17 ++++++++++++-- crates/affinity/src/raster.rs | 44 ++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/crates/affinity/src/model.rs b/crates/affinity/src/model.rs index e629aa7b3..298e32be2 100644 --- a/crates/affinity/src/model.rs +++ b/crates/affinity/src/model.rs @@ -296,12 +296,23 @@ const MAX_TREE_DEPTH: usize = 128; /// Total layer nodes imported. const MAX_NODES: usize = 500_000; +/// Pixels decoded from pixel layers over a whole document (2 GB as RGBA). The archive's limits +/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704). +const MAX_LAYER_PIXELS: u64 = 512 << 20; + /// Read a document: archive, `doc.dat` and the model. pub fn read(bytes: &[u8], limits: Limits) -> Result { + read_within(bytes, limits, MAX_LAYER_PIXELS) +} + +/// [`read`], its pixel layers holding at most `pixels` pixels together. +pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result { let mut archive = Archive::open(bytes, limits)?; let doc = archive.read("doc.dat")?; let s = stream::parse(&doc)?; - Reader::new(&s, &mut archive).document() + let mut reader = Reader::new(&s, &mut archive); + reader.pixels_left = pixels; + reader.document() } pub(crate) struct Reader<'s, 'a, 'b> { @@ -311,11 +322,13 @@ pub(crate) struct Reader<'s, 'a, 'b> { warnings: BTreeMap, nodes: usize, active: HashSet, + /// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all). + pub(crate) pixels_left: u64, } impl<'s, 'a, 'b> Reader<'s, 'a, 'b> { fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self { - Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new() } + Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS } } pub(crate) fn warn(&mut self, what: impl Into) { diff --git a/crates/affinity/src/raster.rs b/crates/affinity/src/raster.rs index 21ecf2d92..98334717d 100644 --- a/crates/affinity/src/raster.rs +++ b/crates/affinity/src/raster.rs @@ -105,10 +105,17 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi return Ok(None); } let (w, h) = (x1 - x0, y1 - y0); - if u64::from(w) * u64::from(h) > MAX_PIXELS { + let pixels = u64::from(w) * u64::from(h); + if pixels > MAX_PIXELS { r.warn("pixel layers larger than 64 megapixels"); return Ok(None); } + // Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract. + let Some(left) = r.pixels_left.checked_sub(pixels) else { + r.warn("pixel layers beyond the pixels one document may hold"); + return Ok(None); + }; + r.pixels_left = left; let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f); let (channels, bps) = match format { Some(0) => (4, 1), @@ -404,6 +411,41 @@ mod tests { } } + /// Pixel layers of one flat colour: nothing in the archive but `doc.dat` (state 2 is a tile + /// of 0xFF). Written by hand for the reader, like [`fixture`]. + fn flat_layers(count: u32, side: i32) -> Vec { + let layer = |i: u32| { + let mut bitmap = vec![(tag(b"Frmt"), F::Enum(0, 0)), (tag(b"BmpW"), F::I32(side)), (tag(b"BmpH"), F::I32(side))]; + for c in 1..=4 { + bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(1))); + bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1))); + bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![2]))); + } + F::Def(10 + i, vec![tag(b"Rstr")], vec![(tag(b"Bitm"), F::Obj(tag(b"DyBm"), bitmap))]) + }; + let spread = F::Def(1, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared((0..count).map(layer).collect()))]); + let doc = synth::stream(&[(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + synth::container(&[("doc.dat", &doc, Method::Zlib)], None) + } + + /// #1704: the pixels of all pixel layers together are bounded, also when the archive holds + /// no data for them: the layers past the allowance are left out with a note. + #[test] + fn pixel_layers_share_one_allowance_for_the_whole_document() { + let file = flat_layers(5, 16); + let layers = |pixels: u64| { + let doc = crate::model::read_within(&file, crate::Limits::default(), pixels).unwrap(); + let n = doc.spreads[0].nodes.iter().filter(|n| matches!(n.kind, Kind::Image(_))).count(); + (n, doc.warnings) + }; + assert_eq!(layers(5 * 256), (5, vec![])); + let (kept, warnings) = layers(3 * 256 + 255); + assert_eq!(kept, 3); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("pixel layers beyond the pixels one document may hold"), "{warnings:?}"); + assert_eq!(crate::read(&file, crate::Limits::default()).unwrap().spreads[0].nodes.len(), 5); + } + #[test] fn embedded_source_dimensions_and_pixel_budget_are_checked_before_decode() { let bytes = png(); From 1d282159dd0fc1d7b18db104c8daafe5eeaba030 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:38:10 +0700 Subject: [PATCH 12/37] Fix #1699: opening a streamed native file unpacks a bounded number of bytes The limits on a compressed file's entries were on their lengths. An entry of one byte at a far offset unpacked everything before it, and each entry behind the previous one started the stream over and unpacked the JSON and the gap again: a small hand-made file could ask for hours of decompression. All of it is now counted against one budget (the entries a writer can make, and the JSON once more), past which the file is refused as too big. --- crates/format/src/unpack.rs | 62 ++++++++++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 4 deletions(-) diff --git a/crates/format/src/unpack.rs b/crates/format/src/unpack.rs index d38f17762..6e9af01d4 100644 --- a/crates/format/src/unpack.rs +++ b/crates/format/src/unpack.rs @@ -122,6 +122,11 @@ impl<'a> Unpacked<'a> { /// data (entries sharing bytes, only in hand-made files, would copy them once each). Entries /// are read in the order of their offsets, each straight into its own buffer. pub(crate) fn entries(self, wanted: &[[u64; 2]]) -> Result>>, FormatError> { + self.entries_within(wanted, MAX_WORK) + } + + /// [`Self::entries`], unpacking at most `work` bytes of a streamed file to find them. + fn entries_within(self, wanted: &[[u64; 2]], work: u64) -> Result>>, FormatError> { let mut out: Vec>> = vec![None; wanted.len()]; let in_memory = |tail: &[u8], out: &mut Vec>>| { for (slot, &[offset, len]) in out.iter_mut().zip(wanted) { @@ -147,7 +152,7 @@ impl<'a> Unpacked<'a> { drop(self.json); let mut order: Vec = (0..wanted.len()).collect(); order.sort_by_key(|&i| wanted.get(i).map_or(0, |w| w[0])); - let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at }; + let mut s = Stream { r: Box::new(io::Cursor::new(read).chain(rest)), pos: 0, file, at, left: work }; for i in order { let Some(&[offset, len]) = wanted.get(i) else { continue }; if let Some(slot) = out.get_mut(i) { @@ -155,6 +160,7 @@ impl<'a> Unpacked<'a> { } } // To the end, so the stream's checksum is verified as before. + s.left = MAX_BLOBS; s.skip(MAX_BLOBS)?; } } @@ -188,12 +194,29 @@ struct Stream<'a> { file: &'a [u8], /// Where the data after the JSON starts in the unpacked stream. at: u64, + /// Bytes that may still be unpacked ([`MAX_WORK`] in all): the entries' own, the gaps before + /// them, and the stream from its start again for each entry behind the one before. + left: u64, } +/// Most bytes unpacked to read a streamed file's entries: the entries and the gaps between them +/// ([`MAX_BLOBS`] in a file a writer made, where they follow each other), and the JSON once more. +/// The entries' lengths alone don't bound it: one byte asked for at a far offset unpacks all +/// that lies before it, and each entry behind the last one starts the stream over (#1699). +const MAX_WORK: u64 = MAX_BLOBS + MAX_JSON; + impl Stream<'_> { + /// Count `n` bytes unpacked against the budget. + fn spend(&mut self, n: u64) -> Result<(), FormatError> { + self.left = self.left.checked_sub(n).ok_or(FormatError::TooBig)?; + Ok(()) + } + /// Unpack and drop up to `n` bytes → how many there were. fn skip(&mut self, n: u64) -> Result { - let skipped = io::copy(&mut (&mut self.r).take(n), &mut io::sink()).map_err(damaged)?; + // One byte past the budget at most, so going over it is seen without doing the work. + let skipped = io::copy(&mut (&mut self.r).take(n.min(self.left.saturating_add(1))), &mut io::sink()).map_err(damaged)?; + self.spend(skipped)?; self.pos += skipped; Ok(skipped) } @@ -204,7 +227,8 @@ impl Stream<'_> { // Entries out of order or overlapping (only hand-made files): unpack again from the // start. let mut dec = GzDecoder::new(self.file); - let skipped = io::copy(&mut (&mut dec).take(self.at), &mut io::sink()).map_err(damaged)?; + let skipped = io::copy(&mut (&mut dec).take(self.at.min(self.left.saturating_add(1))), &mut io::sink()).map_err(damaged)?; + self.spend(skipped)?; if skipped < self.at { return Ok(None); } @@ -219,7 +243,8 @@ impl Stream<'_> { let n = usize::try_from(len).map_err(|_| FormatError::TooBig)?; // The length is the file's word: reserve up to a bound, and let real data grow the rest. bytes.try_reserve_exact(n.min(RESERVE_MAX)).map_err(|_| FormatError::TooBig)?; - let got = (&mut self.r).take(len).read_to_end(&mut bytes).map_err(damaged)?; + let got = (&mut self.r).take(len.min(self.left.saturating_add(1))).read_to_end(&mut bytes).map_err(damaged)?; + self.spend(got as u64)?; self.pos += got as u64; Ok((got as u64 == len).then_some(bytes)) } @@ -237,6 +262,35 @@ mod tests { gz.finish().unwrap() } + /// #1699: reading a streamed file's entries unpacks a bounded number of bytes, whatever + /// their offsets: a far offset and entries behind one another are refused past the budget. + #[test] + fn a_streamed_files_entries_are_found_within_a_work_budget() { + let tail: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + let mut file = b"{}".to_vec(); + file.extend(crate::BLOB_MAGIC); + file.extend(&tail); + let file = gzip(&file); + let read = |wanted: &[[u64; 2]], work: u64| { + let u = Unpacked::new(&file).unwrap(); + assert!(matches!(u.tail, Tail::Stream { .. }), "read as a stream"); + u.entries_within(wanted, work) + }; + // Entries one after the other: the tail is unpacked once. + let parts = read(&[[0, 1000], [1000, 3096]], 4096).unwrap(); + assert_eq!(parts, vec![Some(tail[..1000].to_vec()), Some(tail[1000..].to_vec())]); + // One byte at the far end is the whole tail's work. + assert_eq!(read(&[[4095, 1]], 4096).unwrap(), vec![Some(vec![tail[4095]])]); + assert!(matches!(read(&[[4095, 1]], 4000), Err(FormatError::TooBig))); + // Entries over the same bytes start the stream again each time: counted, then refused. + let same = vec![[4000, 96]; 50]; + assert!(read(&same, 50 * (4096 + 64)).is_ok()); + assert!(matches!(read(&same, 3 * 4096), Err(FormatError::TooBig))); + // An offset past the data is still just missing, and the default budget reads all of it. + assert_eq!(read(&[[1 << 40, 1]], 1 << 20).unwrap(), vec![None]); + assert_eq!(Unpacked::new(&file).unwrap().entries(&[[0, 4096]]).unwrap(), vec![Some(tail.clone())]); + } + #[test] fn entries_sharing_bytes_obey_the_limits_in_memory_too() { // Each entry within MAX_BLOB, together past MAX_BLOBS: many references to the same bytes. From 48041c8cd3dcbcbef76819780869045008d91ab6 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:33:51 +0700 Subject: [PATCH 13/37] Fix #1706: a shadow or glow too large to cache is filtered within the pixel budget The fallback for a drop shadow, outer glow or inner glow whose raster the shadow cache refuses rendered the view plus the blur's spread at full resolution, capped only at 65535 pixels a side: a 1000 pt blur at 3200 % spreads 48000 pixels, so the surface was up to 16 GB whatever the window's size. The region is now planned by filter_region, which renders it at a lower resolution (as filtered content already is) to stay within PIXEL_BUDGET. --- crates/render/src/fx.rs | 64 ++++++++++++++++++++++++++++++++--------- 1 file changed, 51 insertions(+), 13 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 6ae9f11a1..0109bb207 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -366,6 +366,29 @@ fn linear_key(view: Affine) -> [u64; 4] { [c[0].to_bits(), c[1].to_bits(), c[2].to_bits(), c[3].to_bits()] } +/// The raster a shadow or glow blurred by `blur` device pixels is filtered in, for content +/// reaching `reach` (device pixels) in a view of `screen` pixels: (left, top, width, height, +/// scale). It covers what of the reach can show in the view, the blur's spread around it +/// included, at `scale` raster pixels a device pixel: 1, or less to stay within +/// [`PIXEL_BUDGET`]. At a high zoom the spread of a wide blur is tens of thousands of pixels a +/// side, whatever the size of the view (#1706). `None`: nothing to draw. +fn filter_region(reach: Rect, screen: (u16, u16), blur: f64) -> Option<(f64, f64, u16, u16, f64)> { + // 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing. + let spread = blur.max(0.0) * 1.5 + 2.0; + let screen = Rect::new(0.0, 0.0, f64::from(screen.0), f64::from(screen.1)).inflate(spread, spread); + let r = reach.inflate(spread, spread).intersect(screen); + let (x0, y0) = (r.x0.floor(), r.y0.floor()); + let (rw, rh) = (r.x1.ceil() - x0, r.y1.ceil() - y0); + // (Also false for a reach or a blur that isn't a number.) + if !(rw >= 1.0 && rh >= 1.0 && rw.is_finite() && rh.is_finite()) { + return None; + } + let side = f64::from(u16::MAX); + let k = (PIXEL_BUDGET / (rw * rh)).sqrt().min(side / rw).min(side / rh).min(1.0); + let (w, h) = ((rw * k).floor().clamp(1.0, side), (rh * k).floor().clamp(1.0, side)); + Some((x0, y0, w as u16, h as u16, k)) +} + /// Draw `image` with its top-left at (`x`, `y`) device pixels, `1 / k` device pixels a pixel. fn draw_scaled(ctx: &mut RenderContext, image: std::sync::Arc, (x, y): (f64, f64), k: f64) { let (w, h) = (image.width() as f64, image.height() as f64); @@ -1365,18 +1388,9 @@ impl Renderer { if !f.mt { return self.inside_layer(|r| draw(r, ctx, f, composite)); } - // 3σ of the Gaussian (σ = blur / 2) in pixels, plus a pixel of antialiasing. - let spread = blur.max(0.0) * 1.5 / f.px + 2.0; - let screen = Rect::new(0.0, 0.0, ctx.width() as f64, ctx.height() as f64).inflate(spread, spread); - let r = f.view.transform_rect_bbox(reach).inflate(spread, spread).intersect(screen); - let (x0, y0) = (r.x0.floor(), r.y0.floor()); - let (w, h) = ((r.x1.ceil() - x0).min(u16::MAX as f64), (r.y1.ceil() - y0).min(u16::MAX as f64)); - if w < 1.0 || h < 1.0 { - return; - } - let (w, h) = (w as u16, h as u16); + let Some((x0, y0, w, h, k)) = filter_region(f.view.transform_rect_bbox(reach), (ctx.width(), ctx.height()), blur / f.px) else { return }; let mut off = f.offscreen_context(w, h); - let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + let shifted = Frame { mt: false, view: Affine::scale(k) * Affine::translate((-x0, -y0)) * f.view, px: f.px / k, ..*f }; self.inside_layer(|r| draw(r, &mut off, &shifted, None)); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); @@ -1386,7 +1400,12 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); ctx.push_layer(None, Some(blend_mode(m)), Some(o), None, None); } - crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0)); + if k < 1.0 { + draw_scaled(ctx, std::sync::Arc::new(pm), (x0, y0), k); + ctx.set_transform(Affine::IDENTITY); + } else { + crate::group::draw_pixmap(ctx, std::sync::Arc::new(pm), (x0, y0)); + } if layered { ctx.pop_layer(); } @@ -1482,9 +1501,28 @@ mod tests { use vectorcraft_doc::{Appearance, Document, Effect, Node}; use vectorcraft_geom::{Affine, Rect, shapes}; - use super::Drawn; + use super::{Drawn, PIXEL_BUDGET, filter_region}; use crate::{RenderOptions, Rendered, Renderer}; + /// #1706: the raster a shadow or glow is filtered in stays within the pixel budget however + /// far the blur spreads at the zoom (it was up to 65535 pixels a side: 16 GB). + #[test] + fn a_wide_blur_at_a_high_zoom_is_filtered_within_the_pixel_budget() { + // A 100 pt square at 3200 % and at 6400 %, blurred by 1000 pt, in a 1600 × 1000 view. + for zoom in [32.0, 64.0] { + let reach = Rect::new(0.0, 0.0, 100.0 * zoom, 100.0 * zoom); + let (x0, y0, w, h, k) = filter_region(reach, (1600, 1000), 1000.0 * zoom).unwrap(); + assert!(f64::from(w) * f64::from(h) <= PIXEL_BUDGET, "{w} × {h}"); + assert!(k < 1.0 && x0 < 0.0 && y0 < 0.0); + // It still covers the view and the blur's spread around it, at that scale. + assert!(f64::from(w) / k >= 1600.0 + 1500.0 * zoom && f64::from(h) / k >= 1000.0 + 1500.0 * zoom); + } + // A small blur is filtered at full size, cropped to what can show. + assert_eq!(filter_region(Rect::new(10.0, 20.0, 110.0, 70.0), (1600, 1000), 4.0), Some((2.0, 12.0, 116, 66, 1.0))); + assert_eq!(filter_region(Rect::new(5000.0, 0.0, 5100.0, 100.0), (1600, 1000), 4.0), None, "off the view"); + assert_eq!(filter_region(Rect::new(0.0, 0.0, 100.0, 100.0), (1600, 1000), f64::NAN).map(|r| r.4), Some(1.0)); + } + fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document { let mut d = Document::new(100.0, 100.0); let id = d.alloc_id(); From 2e6b925793e898f424fbebba11743191edc821aa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:39:44 +0700 Subject: [PATCH 14/37] Fix #1695: the copy-over fallback of a save keeps the old file until the new one is in Where a folder refuses the temporary file or the rename, the new content (whole and flushed elsewhere) is copied into the file itself: the file was truncated first, so a copy cut short by a lost connection or a crash left neither version in place, and only the new one on disk under a hidden name. The file as it was is now copied and flushed beside the temporary file before it is truncated; if it can't be, the file is left alone and the save fails, naming the temporary file. A failed copy names both. --- crates/format/src/atomic.rs | 99 ++++++++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 11 deletions(-) diff --git a/crates/format/src/atomic.rs b/crates/format/src/atomic.rs index 29444959b..1131d3e2f 100644 --- a/crates/format/src/atomic.rs +++ b/crates/format/src/atomic.rs @@ -4,7 +4,10 @@ //! //! Where a folder refuses the temporary file or the rename (some network shares, a file another //! program holds open), the new content is first written and flushed elsewhere and only then copied -//! into the file itself; if that copy fails, the temporary file stays and the error names it. +//! into the file itself. That copy isn't atomic, so the file as it was is copied and flushed beside +//! the temporary file first (#1695): if the copy into the file fails or is cut short, both versions +//! are on disk, and the error names them. Where that copy of the old file can't be made, the file +//! is left as it was and the save fails. //! //! On macOS `File::sync_all` is a full flush (`F_FULLFSYNC`), which SMB shares refuse with //! `ENOTSUP` while a plain `fsync` works: [`sync_file`] falls back to the plain one there (#1620). @@ -78,8 +81,10 @@ fn write_atomic_stepping( /// The content of `tmp`, which is whole and flushed, copied into `target` itself, and `tmp` /// removed: for a folder that `refused` the atomic replacement. When `target` can't be opened for -/// writing it is untouched: `tmp` is removed and `refused` returned. When the copy fails `target` -/// is damaged: `tmp` stays and the error says where it is. +/// writing it is untouched: `tmp` is removed and `refused` returned. When what it holds can't be +/// copied aside first ([`keep_old`]) it is untouched too: `tmp` stays and the error names it. When +/// the copy into `target` fails it is damaged: `tmp` and the copy of the old content stay and the +/// error says where they are. fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { let opened = File::open(tmp).and_then(|from| Ok((from, OpenOptions::new().write(true).create(true).truncate(false).open(target)?))); let Ok((mut from, mut to)) = opened else { @@ -87,18 +92,42 @@ fn write_over(tmp: &Path, target: &Path, refused: io::Error) -> io::Result<()> { let _ = std::fs::remove_file(tmp); return Err(refused); }; + // Nothing to keep of a file that is new or empty. + let old = to.metadata().map_or(true, |m| m.len() > 0).then(|| tmp.with_extension("old")); + if let Some(old) = &old + && let Err(e) = keep_old(target, old) + { + // Best effort: a partial copy is of no use. + let _ = std::fs::remove_file(old); + let why = + format!("{refused}, and the file can't be copied aside before it is overwritten ({e}); the new content is kept in {}", tmp.display()); + return Err(io::Error::new(refused.kind(), why)); + } let copied = to.set_len(0).and_then(|()| io::copy(&mut from, &mut to)).and_then(|_| sync_file(&to)); drop((from, to)); - match copied { - Ok(()) => { - // Best effort: the file is saved, so the copy is no longer needed. + match (copied, old) { + (Ok(()), old) => { + // Best effort: the file is saved, so the copies are no longer needed. let _ = std::fs::remove_file(tmp); + if let Some(old) = old { + let _ = std::fs::remove_file(old); + } Ok(()) } - Err(e) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))), + (Err(e), Some(old)) => { + Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {}, the file as it was in {})", tmp.display(), old.display()))) + } + (Err(e), None) => Err(io::Error::new(e.kind(), format!("{e} (the new content is kept in {})", tmp.display()))), } } +/// A flushed copy of `target` as it is, at `old` (for its owner only). +fn keep_old(target: &Path, old: &Path) -> io::Result<()> { + let mut to = create_private(old)?; + io::copy(&mut File::open(target)?, &mut to)?; + sync_file(&to) +} + /// A new file for its owner only (Unix): the system's temporary folder is shared. fn create_private(path: &Path) -> io::Result { let mut options = OpenOptions::new(); @@ -185,8 +214,15 @@ fn plain_fsync(file: &File) -> io::Result<()> { fn full_sync(file: &File) -> io::Result<()> { #[cfg(test)] - if REFUSE_FULL_SYNC.with(std::cell::Cell::get) { - return Err(io::Error::from_raw_os_error(ENOTSUP)); + { + if REFUSE_FULL_SYNC.with(std::cell::Cell::get) { + return Err(io::Error::from_raw_os_error(ENOTSUP)); + } + match FLUSHES_LEFT.with(std::cell::Cell::get) { + Some(0) => return Err(io::Error::other("the connection was lost")), + Some(n) => FLUSHES_LEFT.with(|c| c.set(Some(n - 1))), + None => {} + } } file.sync_all() } @@ -203,6 +239,8 @@ thread_local! { static REFUSE_FULL_SYNC: std::cell::Cell = const { std::cell::Cell::new(false) }; /// Tests: the plain `fsync` fallback fails too, with this OS error. static PLAIN_FSYNC_FAILS: std::cell::Cell> = const { std::cell::Cell::new(None) }; + /// Tests: after this many more flushes on this thread, every flush fails. + static FLUSHES_LEFT: std::cell::Cell> = const { std::cell::Cell::new(None) }; /// Tests: how many plain `fsync` fallbacks were made on this thread. static PLAIN_FSYNCS: std::cell::Cell = const { std::cell::Cell::new(0) }; } @@ -363,8 +401,46 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } - /// A refused rename and a copy into the file that fails (here: its flush): the file is damaged, - /// so the temporary file with the whole new content stays and the error names it. + /// #1695: a refused rename and a copy into the file that fails (here: its flush, after the copy + /// of the old content was flushed): the file is damaged, so the temporary file with the whole + /// new content and the copy of the old one stay, and the error names both. + #[test] + fn a_failed_copy_into_the_file_keeps_the_old_content_and_the_new() { + let d = dir("no-rename-copy-fails-late"); + let path = d.join("doc.vectorcraft"); + std::fs::write(&path, b"original").unwrap(); + let r = write_atomic_stepping( + &path, + |f| f.write_all(b"new"), + create_new, + |_, _| { + // One more flush works (the copy of the old content), the next doesn't. + FLUSHES_LEFT.with(|c| c.set(Some(1))); + denied() + }, + ); + FLUSHES_LEFT.with(|c| c.set(None)); + let message = r.unwrap_err().to_string(); + let mut left = leftovers(&d, "doc.vectorcraft"); + left.sort(); + let [old, tmp] = left.as_slice() else { panic!("the old content and the new: {left:?}") }; + assert!(old.ends_with(".old") && tmp.ends_with(".tmp"), "{left:?}"); + assert!(message.contains(tmp.as_str()) && message.contains(old.as_str()), "{message}"); + assert_eq!((std::fs::read(d.join(old)).unwrap(), std::fs::read(d.join(tmp)).unwrap()), (b"original".to_vec(), b"new".to_vec())); + // A new file has nothing to keep, and a save that works leaves neither copy. + std::fs::remove_file(d.join(old)).unwrap(); + std::fs::remove_file(d.join(tmp)).unwrap(); + let fresh = d.join("fresh.vectorcraft"); + write_atomic_stepping(&fresh, |f| f.write_all(b"new"), create_new, |_, _| denied()).unwrap(); + write_atomic_stepping(&fresh, |f| f.write_all(b"newer"), create_new, |_, _| denied()).unwrap(); + assert_eq!(std::fs::read(&fresh).unwrap(), b"newer"); + assert_eq!(leftovers(&d, "doc.vectorcraft"), ["fresh.vectorcraft"]); + let _ = std::fs::remove_dir_all(&d); + } + + /// A refused rename where the old content can't be copied aside (here: no flush works): the + /// file is left as it was, the temporary file with the whole new content stays and the error + /// names it. #[test] fn a_failed_copy_into_the_file_keeps_the_temporary_file() { let d = dir("no-rename-copy-fails"); @@ -388,6 +464,7 @@ mod tests { let [tmp] = left.as_slice() else { panic!("one temporary file: {left:?}") }; assert!(message.contains(tmp.as_str()), "{message}"); assert_eq!(std::fs::read(d.join(tmp)).unwrap(), b"new"); + assert_eq!(std::fs::read(&path).unwrap(), b"original", "not overwritten without a copy of it"); let _ = std::fs::remove_dir_all(&d); } From 779120cb5b85156f66b2b00cd2846cc94b4ef1d5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:34:37 +0700 Subject: [PATCH 15/37] Fix #1705: a Transform effect's copies are bounded by the anchors they make Each Transform effect clamps its own copies to 1000, but each copies all of what the effect before it made: three of them on one square ask for a thousand million squares, from a small file. Copies now stop where the result would pass two million anchors (1000 copies of a 2000-anchor path). --- crates/effects/src/distort.rs | 17 +++++++++++++++++ crates/effects/src/tests.rs | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index aa72ad2d6..8fabb0591 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -108,6 +108,10 @@ const MAX_SCALE: f64 = 100_000.0; const MAX_MOVE: f64 = 1.0e7; /// The Transform effect's copies stop before one reaches further than this (pt) from the origin. const MAX_REACH: f64 = 1.0e9; +/// Most anchors a Transform effect's copies make of a path, the original included: 1000 copies of +/// a 2000-anchor path. Stacked Transform effects multiply their copies (three of 1000 copies make +/// a thousand million of one square), so the limit is on the result, not on each effect's count. +pub(crate) const MAX_COPY_ANCHORS: usize = 2_000_000; /// Transform effect: scale, rotate and reflect about reference point `reference` of the bounds /// (the 9-point grid, 4 = centre), then move; `copies` more times, each from the last. With @@ -115,6 +119,11 @@ const MAX_REACH: f64 = 1.0e9; /// the angle a share of theirs, drawn from `seed` (the object's), so an object keeps its result /// on every redraw while each object varies its own way. pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData { + transform_within(path, b, p, seed, MAX_COPY_ANCHORS) +} + +/// [`transform`] whose copies make at most `max` anchors of the path, the original included. +pub(crate) fn transform_within(path: &PathData, b: Rect, p: &Value, seed: u64, max: usize) -> PathData { let random = flag(p, "random", false); // The share (0..1) of value `k` applied. let share = |k: u64| if random { (noise(seed, k, 0, 0) + 1.0) / 2.0 } else { 1.0 }; @@ -132,9 +141,17 @@ pub fn transform(path: &PathData, b: Rect, p: &Value, seed: u64) -> PathData { return path.transformed(m); } let within = |r: Rect| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.abs() <= MAX_REACH); + // Each copy adds the path once more, and a Transform after this one copies the lot again: + // copies stop where the result would pass [`MAX_COPY_ANCHORS`] (#1705). + let each = path.anchor_count(); + let mut anchors = each; let mut out = path.clone(); let mut cur = path.clone(); for _ in 0..copies { + anchors = anchors.saturating_add(each); + if anchors > max { + break; + } cur = cur.transformed(m); if !cur.bounds().is_some_and(within) { break; diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index ffafc9f81..944308758 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -206,6 +206,24 @@ fn random_transform_is_stable_per_object_and_within_the_values() { assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 90.0, 1e-9), "{r:?}"); } +/// #1705: stacked Transform effects multiply their copies; the anchors they make are bounded, +/// whatever each one's count. +#[test] +fn stacked_transform_copies_stay_within_the_anchor_budget() { + let p = json!({"copies": 1000}); + let b = square().bounds().unwrap(); + let mut path = square(); + let mut counts = vec![]; + for _ in 0..3 { + path = crate::distort::transform_within(&path, b, &p, 0, 100); + counts.push(path.anchor_count()); + } + assert_eq!(counts, [100, 100, 100], "25 squares, then no room for a copy of them"); + // Within the budget every copy is made, and the effect's own budget takes a thousand copies. + assert_eq!(crate::distort::transform_within(&square(), b, &json!({"copies": 24}), 0, 100).subpaths.len(), 25); + assert_eq!(run("distort.transform", p, &square()).subpaths.len(), 1001); +} + #[test] fn transform_copies_stop_before_they_overflow() { let out = run("distort.transform", json!({"scaleH": 100000, "scaleV": 100000, "copies": 1000}), &square()); From a6d5c461a100c07939af17b785cd4ef446109b0c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:36:04 +0700 Subject: [PATCH 16/37] Fix #1724: 3D effects on a nonzero compound path keep the area its contours fill Extrude & Bevel, Inflate and Rotate built their solid from the outline by nesting alone: a contour inside an odd number of others was a hole. Under the nonzero rule a contour inside another that runs the same way encloses filled art, so the effect cut a hole the object doesn't have. The fill rule now reaches the outline builder, which leaves out contours with filled art on both sides before pairing outlines and holes. --- crates/effects/src/solids.rs | 26 ++--- crates/three-d/src/solid.rs | 38 +++++-- crates/three-d/src/tests_solid.rs | 164 +++++++++++++++++++----------- 3 files changed, 145 insertions(+), 83 deletions(-) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 0650e09c9..410d923f3 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -113,19 +113,19 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> { } } -fn facets(id: &str, path: &PathData, bounds: Rect, p: &Value) -> Result, String> { +fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result, String> { let view = view_options(p); match id { - EXTRUDE => vectorcraft_three_d::extrude(path, bounds, extrude_options(p), view), - INFLATE => vectorcraft_three_d::inflate(path, bounds, inflate_options(p), view), - _ => vectorcraft_three_d::plane(path, bounds, view), + EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view), + INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view), + _ => vectorcraft_three_d::plane(path, rule, bounds, view), } } /// Validate before a command changes the document; the evaluator bounds loaded files too. pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…')); - let (path, _) = + let (path, rule) = profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?; let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; if let Some(v) = p.get("bevel") @@ -134,7 +134,7 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> { let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect(); return Err(format!("bevel must be none or one of {}", names.join(", "))); } - facets(id, &path, b, p).map(|_| ()) + facets(id, &path, rule, b, p).map(|_| ()) } /// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and @@ -185,11 +185,12 @@ fn paint_of(item: &AppearanceItem) -> (Paint, f32, BlendMode) { } } -/// The area `item` paints on `path`: the path itself for a fill, the stroke's outline for a stroke. -fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> PathData { +/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the +/// stroke's outline (whose contours nest as its holes do) for a stroke. +fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) { match item { - AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => crate::stroke::outline_region(path, rule, s), - _ => path.clone(), + AppearanceItem::Stroke(s) if s.width > 0.0 && s.width.is_finite() => (crate::stroke::outline_region(path, rule, s), FillRule::EvenOdd), + _ => (path.clone(), rule), } } @@ -204,7 +205,8 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App let mut out = vec![]; for item in items { let (paint, opacity, blend) = paint_of(item); - let f = facets(ROTATE, ®ion(path, rule, item), b, &p).ok()?; + let (area, rule) = region(path, rule, item); + let f = facets(ROTATE, &area, rule, b, &p).ok()?; out.push(Arc::new(surface(n, f, &paint, opacity, blend))); } return Some(out); @@ -213,7 +215,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App let (paint, opacity, blend) = paint_of(item); // A loaded file or a shape edited past the limits keeps drawing its source path instead of // failing the frame; commands validate the same evaluator and report its errors. - let f = facets(&fx.id, path, b, &p).ok()?; + let f = facets(&fx.id, path, rule, b, &p).ok()?; Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))]) } diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index de9ca38d6..bb16efa21 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -9,7 +9,7 @@ //! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away //! from the viewer are left out, the rest are drawn back to front. -use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect}; +use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect}; use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale}; @@ -290,7 +290,11 @@ fn pieces(c: vectorcraft_geom::CubicBez, tolerance: f64) -> u32 { /// outlines run one way and holes the other, so nonzero winding fills them as the art is. Curves /// are followed within a tolerance of 1/2000 of `size` (at least 0.05 pt), so small curves take /// few points and large ones more; more than `limit` points in all is refused. -fn rings(path: &PathData, size: f64, limit: usize) -> Result>, String> { +/// +/// Under the nonzero `rule` an outline with filled art on both sides is no edge of the shape and +/// is left out: a contour inside another that runs the same way doesn't cut a hole, as it would +/// if nesting alone decided (#1724). +fn rings(path: &PathData, rule: FillRule, size: f64, limit: usize) -> Result>, String> { let tolerance = (size / 2000.0).max(0.05); let mut count = 0; let mut out = vec![]; @@ -325,6 +329,18 @@ fn rings(path: &PathData, size: f64, limit: usize) -> Result>, St out.push(ring); } } + if rule == FillRule::NonZero { + let turn = |r: &[Point]| if signed_area(r) > 0.0 { 1 } else { -1 }; + let mut edge = Vec::with_capacity(out.len()); + for (i, r) in out.iter().enumerate() { + let p = r.first().copied().unwrap_or_default(); + // The winding number just outside the ring, and just inside it. + let outside: i32 = out.iter().enumerate().filter(|(j, o)| *j != i && contains(o, p)).map(|(_, o)| turn(o)).sum(); + edge.push((outside != 0) != (outside + turn(r) != 0)); + } + let mut edge = edge.into_iter(); + out.retain(|_| edge.next().unwrap_or(true)); + } // A ring inside an odd number of others is a hole. let holes: Vec = out .iter() @@ -634,14 +650,14 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. -pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> Result, String> { +pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane(path, bounds, view); + return plane(path, rule, bounds, view); } - let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; let camera = Camera::new(view, bounds, o.depth); let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; let steps = steps(&o); @@ -675,9 +691,9 @@ pub fn extrude(path: &PathData, bounds: Rect, options: Extrude, view: View) -> R /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). -pub fn plane(path: &PathData, bounds: Rect, view: View) -> Result, String> { +pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { check_bounds(bounds)?; - let rings = rings(path, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; + let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; let camera = Camera::new(view, bounds, 0.0); let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); @@ -997,18 +1013,18 @@ fn material(outline: &PathData, cell: Rect) -> Option>> { &[Point::new(cell.x0, cell.y0), Point::new(cell.x1, cell.y0), Point::new(cell.x1, cell.y1), Point::new(cell.x0, cell.y1)], true, )); - let rule = vectorcraft_geom::FillRule::NonZero; + let rule = FillRule::NonZero; let part = vectorcraft_pathops::try_boolean(&r, rule, outline, rule, vectorcraft_pathops::BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION) .ok()?; - rings(&part, cell.width(), MAX_PLANE_POINTS).ok() + rings(&part, rule, cell.width(), MAX_PLANE_POINTS).ok() } /// Inflate: the shape puffed out like a cushion, its edge kept where the art's outline is. -pub fn inflate(path: &PathData, bounds: Rect, options: Inflate, view: View) -> Result, String> { +pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, view: View) -> Result, String> { check_bounds(bounds)?; let o = options.sanitized(); - let rings = rings(path, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; + let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; if rings.is_empty() { return Ok(vec![]); } diff --git a/crates/three-d/src/tests_solid.rs b/crates/three-d/src/tests_solid.rs index 657fba651..72f007cef 100644 --- a/crates/three-d/src/tests_solid.rs +++ b/crates/three-d/src/tests_solid.rs @@ -1,4 +1,4 @@ -use vectorcraft_geom::{BezPath, PathData, Point, Rect}; +use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect}; use super::*; @@ -41,7 +41,7 @@ fn close(a: f64, b: f64, tol: f64) -> bool { #[test] fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() { let (p, b) = shape(&square(10.0, 20.0, 100.0)); - let f = extrude(&p, b, Extrude::default(), front()).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), front()).unwrap(); assert_eq!(f.len(), 1, "the walls and back face away: only the front cap shows"); assert_eq!(f[0].contours.len(), 1); assert_eq!(extent(&f), Rect::new(10.0, 20.0, 110.0, 120.0)); @@ -51,7 +51,7 @@ fn seen_straight_on_an_extrusion_is_its_front_face_exactly_where_the_art_is() { fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let o = Extrude { depth: 40.0, ..Extrude::default() }; - let f = extrude(&p, b, o, View { rotation_y: 30.0, ..front() }).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, b, o, View { rotation_y: 30.0, ..front() }).unwrap(); // Orthographic: the silhouette is the front's width times cos 30° plus the depth times sin 30°. let w = extent(&f).width(); assert!(close(w, 100.0 * 30f64.to_radians().cos() + 40.0 * 30f64.to_radians().sin(), 1e-6), "{w}"); @@ -66,10 +66,10 @@ fn turned_about_y_the_front_foreshortens_and_one_side_wall_shows() { fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() }; - let flat = extrude(&p, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap(); - assert_eq!(flat, plane(&p, b, v).unwrap()); - let solid = extrude(&p, b, Extrude::default(), v).unwrap(); - let hollow = extrude(&p, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap(); + let flat = extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 0.0, ..Extrude::default() }, v).unwrap(); + assert_eq!(flat, plane(&p, FillRule::EvenOdd, b, v).unwrap()); + let solid = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap(); + let hollow = extrude(&p, FillRule::EvenOdd, b, Extrude { cap: false, ..Extrude::default() }, v).unwrap(); // Solid: front, right and top faces. Hollow: all four walls (two seen from inside), no caps. assert_eq!(solid.len(), 3); assert_eq!(hollow.len(), 4); @@ -81,10 +81,10 @@ fn taper_shrinks_the_back_and_twist_turns_it() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let side = View { rotation_y: 90.0, ..front() }; let o = Extrude { depth: 60.0, cap: false, ..Extrude::default() }; - let straight = extent(&extrude(&p, b, o, side).unwrap()); + let straight = extent(&extrude(&p, FillRule::EvenOdd, b, o, side).unwrap()); assert!(close(straight.height(), 100.0, 1e-6) && close(straight.width(), 60.0, 1e-6)); // Seen from the side, a 50 % taper keeps the front's full height and halves the back's. - let tapered = extrude(&p, b, Extrude { taper: 0.5, ..o }, side).unwrap(); + let tapered = extrude(&p, FillRule::EvenOdd, b, Extrude { taper: 0.5, ..o }, side).unwrap(); let back: Vec = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x0, 1e-6)).collect(); let (lo, hi) = back.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), q| (lo.min(q.y), hi.max(q.y))); let at_front: Vec = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, straight.x1, 1e-6)).collect(); @@ -93,11 +93,11 @@ fn taper_shrinks_the_back_and_twist_turns_it() { assert!(close(front_h, 100.0, 1e-6) && close(back_h, 50.0, 1e-6), "front {front_h}, back {back_h}"); // Seen from behind, a 45° twist shows the back cap turned into a diamond. let back = View { rotation_y: 180.0, ..front() }; - let twisted = extrude(&p, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap(); + let twisted = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, cap: true, ..o }, back).unwrap(); assert_eq!(twisted.len(), 1, "only the back cap faces the viewer"); assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted)); // Its walls are cut into slices that follow the turn. - let open = extrude(&p, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); + let open = extrude(&p, FillRule::EvenOdd, b, Extrude { twist: 45.0, ..o }, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); assert!(open.len() > 2 * 8, "{}", open.len()); } @@ -105,13 +105,13 @@ fn taper_shrinks_the_back_and_twist_turns_it() { fn a_bevel_inside_shrinks_the_cap_and_outside_grows_the_walls() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let bevel = Bevel { width: 10.0, height: 10.0, ..Bevel::default() }; - let inside = extrude(&p, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap(); + let inside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { inside: true, ..bevel }), ..Extrude::default() }, front()).unwrap(); // Straight on: the cap inset by the width, and the chamfer around it filling the outline. let cap = inside.iter().find(|f| close(signed_area(&f.contours[0]).abs(), 80.0 * 80.0, 1e-6)); assert!(cap.is_some(), "an 80 × 80 cap"); assert_eq!(extent(&inside), Rect::new(0.0, 0.0, 100.0, 100.0)); assert!(close(area(&inside), 100.0 * 100.0, 1e-6)); - let outside = extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap(); + let outside = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, front()).unwrap(); assert_eq!(extent(&outside), Rect::new(-10.0, -10.0, 110.0, 110.0), "the walls stand 10 pt outside the art"); assert!(outside.iter().any(|f| close(signed_area(&f.contours[0]).abs(), 100.0 * 100.0, 1e-6)), "the cap is the art"); // The chamfer's faces lean towards the light differently from the cap. @@ -123,12 +123,16 @@ fn bevel_shapes_repeats_and_both_sides_change_the_surface() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; let base = Bevel { width: 12.0, height: 12.0, ..Bevel::default() }; - let classic = extrude(&p, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap(); + let classic = extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(base), ..Extrude::default() }, v).unwrap(); for shape in BevelShape::ALL.into_iter().skip(1) { - assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), "{shape:?}"); + assert_ne!( + classic, + extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(Bevel { shape, ..base }), ..Extrude::default() }, v).unwrap(), + "{shape:?}" + ); } for bevel in [Bevel { repeat: 3, ..base }, Bevel { repeat: 3, space: 0.3, ..base }, Bevel { both_sides: true, ..base }] { - assert_ne!(classic, extrude(&p, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}"); + assert_ne!(classic, extrude(&p, FillRule::EvenOdd, b, Extrude { bevel: Some(bevel), ..Extrude::default() }, v).unwrap(), "{bevel:?}"); } // The step shape keeps its corners: half the width at half the height. assert_eq!(BevelShape::Step.at(0.5), (0.5, 0.5)); @@ -147,11 +151,11 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() { // The hole drawn the same way round as the outline: even-odd nesting still makes it a hole. b.extend(square(30.0, 30.0, 40.0)); let (p, r) = shape(&b); - let f = extrude(&p, r, Extrude::default(), front()).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap(); assert_eq!(f.len(), 1); assert_eq!(f[0].contours.len(), 2); assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-6)); - let turned = extrude(&p, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap(); + let turned = extrude(&p, FillRule::EvenOdd, r, Extrude::default(), View { rotation_y: 40.0, ..front() }).unwrap(); // Front cap, the outline's right wall and the hole's left wall (seen through the hole). assert_eq!(turned.len(), 3); } @@ -159,15 +163,15 @@ fn holes_stay_holes_in_the_caps_and_get_walls_of_their_own() { #[test] fn a_plane_turns_about_the_given_centre() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); - let f = plane(&p, b, View { rotation_y: 60.0, ..front() }).unwrap(); + let f = plane(&p, FillRule::EvenOdd, b, View { rotation_y: 60.0, ..front() }).unwrap(); assert_eq!(f.len(), 1); let e = extent(&f); assert!(close(e.width(), 50.0, 1e-6) && close(e.center().x, 50.0, 1e-9)); // Turned about the centre of a larger box, the square moves as well as narrowing. - let wide = plane(&p, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap(); + let wide = plane(&p, FillRule::EvenOdd, Rect::new(0.0, 0.0, 300.0, 100.0), View { rotation_y: 60.0, ..front() }).unwrap(); assert!(close(extent(&wide).x0, 150.0 - 150.0 * 0.5, 1e-6)); // Its back shows when turned past 90°: a plane is two-sided. - assert_eq!(plane(&p, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1); + assert_eq!(plane(&p, FillRule::EvenOdd, b, View { rotation_y: 150.0, ..front() }).unwrap().len(), 1); } #[test] @@ -175,7 +179,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() { let mut c = BezPath::new(); c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); - let f = inflate(&p, b, Inflate::default(), front()).unwrap(); + let f = inflate(&p, FillRule::EvenOdd, b, Inflate::default(), front()).unwrap(); // Straight on, the cushion covers the circle (sampled as a polygon) and nothing else. assert!(close(area(&f), std::f64::consts::PI * 50.0 * 50.0, 60.0), "{}", area(&f)); assert!(extent(&f).x0 >= 50.0 - 1e-6 && extent(&f).x1 <= 150.0 + 1e-6); @@ -184,7 +188,7 @@ fn inflate_covers_the_shape_and_puffs_by_the_volume() { assert!(hi - lo > 0.3, "{lo}..{hi}"); // From the side: 50 % volume rounds a disc into a half sphere on a flat back. let side = View { rotation_y: 90.0, ..front() }; - let thick = |o: Inflate| extent(&inflate(&p, b, o, side).unwrap()).width(); + let thick = |o: Inflate| extent(&inflate(&p, FillRule::EvenOdd, b, o, side).unwrap()).width(); assert!(close(thick(Inflate::default()), 50.0, 2.0), "{}", thick(Inflate::default())); assert!(close(thick(Inflate { both_sides: true, ..Inflate::default() }), 100.0, 4.0)); assert!(close(thick(Inflate { depth: 30.0, ..Inflate::default() }), 80.0, 2.0)); @@ -197,7 +201,7 @@ fn inflate_leaves_holes_open() { let mut b = square(0.0, 0.0, 100.0); b.extend(square(30.0, 30.0, 40.0)); let (p, r) = shape(&b); - let f = inflate(&p, r, Inflate::default(), front()).unwrap(); + let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap(); assert!(close(area(&f), 100.0 * 100.0 - 40.0 * 40.0, 1e-3), "{}", area(&f)); } @@ -205,9 +209,9 @@ fn inflate_leaves_holes_open() { fn malformed_and_degenerate_input_is_bounded() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); for r in [Rect::new(f64::NAN, 0.0, 1.0, 1.0), Rect::new(0.0, 0.0, 1e9, 1.0)] { - assert!(extrude(&p, r, Extrude::default(), front()).is_err()); - assert!(inflate(&p, r, Inflate::default(), front()).is_err()); - assert!(plane(&p, r, front()).is_err()); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_err()); + assert!(inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).is_err()); + assert!(plane(&p, FillRule::EvenOdd, r, front()).is_err()); } let wild = Extrude { depth: f64::INFINITY, @@ -216,18 +220,24 @@ fn malformed_and_degenerate_input_is_bounded() { cap: true, bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }), }; - let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap(); + let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap(); assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite())); assert!( - inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front()) - .is_ok() + inflate( + &p, + FillRule::EvenOdd, + b, + Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, + front() + ) + .is_ok() ); - assert!(extrude(&PathData::default(), b, Extrude::default(), front()).unwrap().is_empty()); + assert!(extrude(&PathData::default(), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty()); // A line has no area to extrude. let mut line = BezPath::new(); line.move_to((0.0, 0.0)); line.line_to((100.0, 0.0)); - assert!(extrude(&PathData::from_bezpath(&line), b, Extrude::default(), front()).unwrap().is_empty()); + assert!(extrude(&PathData::from_bezpath(&line), FillRule::EvenOdd, b, Extrude::default(), front()).unwrap().is_empty()); // Too many anchors: refused, not truncated. let mut many = BezPath::new(); many.move_to((0.0, 0.0)); @@ -235,15 +245,15 @@ fn malformed_and_degenerate_input_is_bounded() { many.line_to((f64::from(i), f64::from(i % 7))); } many.close_path(); - assert!(extrude(&PathData::from_bezpath(&many), b, Extrude::default(), front()).is_err()); + assert!(extrude(&PathData::from_bezpath(&many), FillRule::EvenOdd, b, Extrude::default(), front()).is_err()); } #[test] fn perspective_makes_the_near_face_larger() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let o = Extrude { depth: 100.0, ..Extrude::default() }; - let flat = extent(&extrude(&p, b, o, front()).unwrap()); - let deep = extent(&extrude(&p, b, o, View { perspective: 30.0, ..front() }).unwrap()); + let flat = extent(&extrude(&p, FillRule::EvenOdd, b, o, front()).unwrap()); + let deep = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: 30.0, ..front() }).unwrap()); assert!(deep.width() > flat.width() + 1.0); assert!(close(deep.center().x, flat.center().x, 1e-6)); } @@ -257,7 +267,7 @@ fn a_wider_perspective_brings_the_eye_nearer() { let mut last = 100.0; for angle in [20.0, 60.0, 110.0, 160.0] { // The front face alone shows: its width is the cube's on the page. - let width = extent(&extrude(&p, b, o, View { perspective: angle, ..front() }).unwrap()).width(); + let width = extent(&extrude(&p, FillRule::EvenOdd, b, o, View { perspective: angle, ..front() }).unwrap()).width(); assert!(width > last, "{angle}°: {width} after {last}"); last = width; } @@ -274,10 +284,10 @@ fn the_inflate_membrane_matches_known_solutions() { let mut c = BezPath::new(); c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((100.0, 100.0), 50.0), 0.01)); let (p, b) = shape(&c); - let disc = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49); + let disc = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), b.width() / 48.0, 49, 49); assert!(close(disc.peak, 50.0 * 50.0 / 4.0, 625.0 * 0.02), "{}", disc.peak); let (p, b) = shape(&square(0.0, 0.0, 100.0)); - let sq = Membrane::solve(&rings(&p, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); + let sq = Membrane::solve(&rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap(), b.origin(), 100.0 / 48.0, 49, 49); assert!(close(sq.peak, 0.07367 * 100.0 * 100.0, 736.7 * 0.02), "{}", sq.peak); // Smooth across the middle: no crease where a distance-to-the-edge height would have one // (the diagonals). The second difference along a diagonal stays small and even. @@ -303,10 +313,10 @@ fn inflate_cells_across_a_hole_edge_are_cut_to_the_shape() { b.close_path(); let (p, r) = shape(&b); let v = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..front() }; - let f = inflate(&p, r, Inflate::default(), v).unwrap(); + let f = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), v).unwrap(); assert!(f.iter().all(|f| f.contours.len() == 1), "every facet is one piece of the surface"); // Straight on, the facets still cover exactly the shape. - let flat = inflate(&p, r, Inflate::default(), front()).unwrap(); + let flat = inflate(&p, FillRule::EvenOdd, r, Inflate::default(), front()).unwrap(); assert!(close(area(&flat), 220.0 * 220.0 - 100.0 * 90.0, 1e-3), "{}", area(&flat)); } @@ -332,19 +342,19 @@ fn bumps(count: usize) -> (PathData, Rect) { fn small_curves_take_few_points_and_rotate_takes_large_outlines() { // 300 curves were 4,800 points at sixteen each, over the old limit of 1,024. let (p, r) = bumps(300); - assert!(extrude(&p, r, Extrude::default(), front()).is_ok()); - assert!(inflate(&p, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); - assert!(plane(&p, r, front()).is_ok()); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).is_ok()); + assert!(inflate(&p, FillRule::EvenOdd, r, Inflate { volume: 20.0, ..Inflate::default() }, front()).is_ok()); + assert!(plane(&p, FillRule::EvenOdd, r, front()).is_ok()); // 4,000 curves: too many for a solid, not for Rotate. let (p, r) = bumps(4000); - assert!(extrude(&p, r, Extrude::default(), front()).unwrap_err().contains("too complex")); - assert_eq!(plane(&p, r, front()).unwrap().len(), 1); + assert!(extrude(&p, FillRule::EvenOdd, r, Extrude::default(), front()).unwrap_err().contains("too complex")); + assert_eq!(plane(&p, FillRule::EvenOdd, r, front()).unwrap().len(), 1); // Each piece stays within the tolerance of the curve: a circle's chords sag at most 0.05 pt // here (1/2000 of its 100 pt size is less). let mut c = BezPath::new(); c.extend(vectorcraft_geom::kurbo::Shape::path_elements(&vectorcraft_geom::kurbo::Circle::new((50.0, 50.0), 50.0), 0.01)); let (p, r) = shape(&c); - let ring = &rings(&p, 100.0, MAX_SOLID_POINTS).unwrap()[0]; + let ring = &rings(&p, FillRule::EvenOdd, 100.0, MAX_SOLID_POINTS).unwrap()[0]; for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)) { let mid = a.midpoint(*b); assert!(50.0 - mid.distance(Point::new(50.0, 50.0)) <= 0.05 + 1e-3, "{a:?} {b:?}"); @@ -352,7 +362,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { assert!(r.width() > 0.0); // A small bump takes a few points; a large curve more than sixteen where it needs them. let (p, r) = bumps(300); - let small = rings(&p, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len(); + let small = rings(&p, FillRule::EvenOdd, r.width().max(r.height()), MAX_SOLID_POINTS).unwrap()[0].len(); assert!(small <= 300 * 4 + 3, "{small} points for 300 bumps"); assert!(ring.len() > 4 * 16, "a 100 pt circle within 0.05 pt: {}", ring.len()); } @@ -362,7 +372,7 @@ fn small_curves_take_few_points_and_rotate_takes_large_outlines() { #[test] fn the_edge_index_finds_the_nearest_edge() { let (p, r) = bumps(200); - let rings = rings(&p, r.width(), MAX_SOLID_POINTS).unwrap(); + let rings = rings(&p, FillRule::EvenOdd, r.width(), MAX_SOLID_POINTS).unwrap(); let edges = Edges::new(&rings); for k in 0..400 { let t = f64::from(k); @@ -376,7 +386,7 @@ fn the_edge_index_finds_the_nearest_edge() { /// straight ahead: the search doesn't stop before it has looked there. #[test] fn the_edge_index_looks_along_the_side_from_outside() { - let ring = |b: BezPath| rings(&shape(&b).0, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); + let ring = |b: BezPath| rings(&shape(&b).0, FillRule::EvenOdd, 64.0, MAX_SOLID_POINTS).unwrap().remove(0); // Straight ahead of the point, a little further up its side, and the far corner (a 64 pt grid). let rings = vec![ring(square(2.5, 0.0, 1.0)), ring(square(0.0, 4.2, 0.8)), ring(square(63.0, 63.0, 1.0))]; let edges = Edges::new(&rings); @@ -390,7 +400,7 @@ fn the_edge_index_looks_along_the_side_from_outside() { fn twist_holds_between_0_and_360_degrees() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); let v = View { rotation_x: -20.0, rotation_y: 30.0, ..front() }; - let at = |twist: f64| extrude(&p, b, Extrude { twist, ..Extrude::default() }, v).unwrap(); + let at = |twist: f64| extrude(&p, FillRule::EvenOdd, b, Extrude { twist, ..Extrude::default() }, v).unwrap(); assert_eq!(at(-45.0), at(0.0)); assert_eq!(at(500.0), at(360.0)); assert_ne!(at(45.0), at(0.0)); @@ -408,11 +418,11 @@ fn twisted_walls_face_the_way_they_turn() { for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0)] { let v = View { rotation_x: x, rotation_y: y, ..front() }; let o = Extrude { twist, ..Extrude::default() }; - let got = extrude(&p, b, o, v).unwrap(); + let got = extrude(&p, FillRule::EvenOdd, b, o, v).unwrap(); // Expected: walls (and caps) whose outward normal faces the viewer. let o = o.sanitized(); let camera = Camera::new(v, b, o.depth); - let ring = &rings(&p, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0]; + let ring = &rings(&p, FillRule::EvenOdd, b.width().max(b.height()), MAX_SOLID_POINTS).unwrap()[0]; let slices = ((twist / 6.0).ceil() as usize).clamp(8, 64); let z = |t: f64| o.depth / 2.0 - o.depth * t; let mut expected = 0; @@ -456,7 +466,7 @@ fn inflate_twists_tapers_and_opens_its_back() { let o = Inflate { depth: 60.0, volume: 0.0, ..Inflate::default() }; // From the side, a flat (0 % volume) cushion is the extrusion: front 100 high, the back 50 // at a 50 % taper. - let tapered = inflate(&p, b, Inflate { taper: 0.5, ..o }, side).unwrap(); + let tapered = inflate(&p, FillRule::EvenOdd, b, Inflate { taper: 0.5, ..o }, side).unwrap(); let e = extent(&tapered); assert!(close(e.width(), 60.0, 1e-6) && close(e.height(), 100.0, 1e-6), "{e:?}"); let back: Vec = tapered.iter().flat_map(|f| f.contours.iter().flatten().copied()).filter(|q| close(q.x, e.x0, 1e-6)).collect(); @@ -464,18 +474,19 @@ fn inflate_twists_tapers_and_opens_its_back() { assert!(close(hi - lo, 50.0, 1e-6), "back {}", hi - lo); // From behind, a 45° twist shows the flat back as a diamond. let behind = View { rotation_y: 180.0, ..front() }; - let twisted = inflate(&p, b, Inflate { twist: 45.0, ..o }, behind).unwrap(); + let twisted = inflate(&p, FillRule::EvenOdd, b, Inflate { twist: 45.0, ..o }, behind).unwrap(); assert!(close(extent(&twisted).width(), 100.0 * 2f64.sqrt(), 1e-6), "{:?}", extent(&twisted)); // Hollow: no back; from behind the inside of the inflated front shows instead of a flat face. - let solid = inflate(&p, b, Inflate { volume: 50.0, ..o }, behind).unwrap(); + let solid = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, ..o }, behind).unwrap(); assert_eq!(solid.len(), 1, "the flat back alone"); - let hollow = inflate(&p, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap(); + let hollow = inflate(&p, FillRule::EvenOdd, b, Inflate { volume: 50.0, cap: false, ..o }, behind).unwrap(); assert!(hollow.len() > 100 && hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() < 12)); // With both sides puffed, the back puff is scaled with the taper: lower and smaller. - let both = |taper: f64| extent(&inflate(&p, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap()); + let both = + |taper: f64| extent(&inflate(&p, FillRule::EvenOdd, b, Inflate { both_sides: true, volume: 50.0, taper, depth: 0.0, ..o }, side).unwrap()); assert!(both(0.5).width() < both(1.0).width() && both(0.5).height() <= both(1.0).height() + 1e-6); // Out of range values hold at the documented ends. - let at = |i: Inflate| inflate(&p, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); + let at = |i: Inflate| inflate(&p, FillRule::EvenOdd, b, i, View { rotation_x: -20.0, rotation_y: 30.0, ..front() }).unwrap(); assert_eq!(at(Inflate { twist: -30.0, ..o }), at(o)); assert_eq!(at(Inflate { taper: 0.0, ..o }), at(Inflate { taper: 0.01, ..o })); } @@ -487,10 +498,43 @@ fn a_flat_twisted_inflate_matches_the_extrusion() { let (p, b) = shape(&square(0.0, 0.0, 100.0)); for (x, y) in [(-20.0, 30.0), (0.0, 90.0), (30.0, -60.0), (-45.0, 150.0), (60.0, 200.0)] { let v = View { rotation_x: x, rotation_y: y, ..front() }; - let e = extent(&extrude(&p, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap()); - let i = extent(&inflate(&p, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap()); + let e = extent(&extrude(&p, FillRule::EvenOdd, b, Extrude { depth: 60.0, twist: 120.0, taper: 0.6, ..Extrude::default() }, v).unwrap()); + let i = extent( + &inflate(&p, FillRule::EvenOdd, b, Inflate { depth: 60.0, twist: 120.0, taper: 0.6, volume: 0.0, ..Inflate::default() }, v).unwrap(), + ); for (a, c) in [(e.x0, i.x0), (e.y0, i.y0), (e.x1, i.x1), (e.y1, i.y1)] { assert!(close(a, c, 1e-6), "view ({x}, {y}): extrusion {e:?}, inflate {i:?}"); } } } + +/// #1724: under the nonzero rule a contour inside another that runs the same way cuts no hole +/// (the middle is filled, as the art is drawn); one running the other way does, and so does +/// either under the even-odd rule. +#[test] +fn nonzero_shapes_keep_the_middle_a_same_way_contour_encloses() { + let rect = |r: Rect, clockwise: bool| { + let mut pts = vec![Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]; + if clockwise { + pts.reverse(); + } + vectorcraft_geom::SubPath::polyline(&pts, true) + }; + let (outer, inner) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(25.0, 25.0, 75.0, 75.0)); + let nested = |inner_clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, inner_clockwise)]); + let flat = |p: &PathData, rule: FillRule| area(&plane(p, rule, outer, front()).unwrap()); + let (solid, holed) = (100.0 * 100.0, 100.0 * 100.0 - 50.0 * 50.0); + assert!(close(flat(&nested(false), FillRule::NonZero), solid, 1e-6), "the same way round: filled"); + assert!(close(flat(&nested(true), FillRule::NonZero), holed, 1e-6), "the other way round: a hole"); + assert!(close(flat(&nested(false), FillRule::EvenOdd), holed, 1e-6)); + assert!(close(flat(&nested(true), FillRule::EvenOdd), holed, 1e-6)); + // A third contour inside the hole is an island again, whichever way it runs. + let island = |clockwise: bool| PathData::new(vec![rect(outer, false), rect(inner, true), rect(Rect::new(40.0, 40.0, 60.0, 60.0), clockwise)]); + for clockwise in [false, true] { + assert!(close(flat(&island(clockwise), FillRule::NonZero), holed + 20.0 * 20.0, 1e-6), "{clockwise}"); + } + // Extrude's caps and Inflate follow the same outline. + let caps = |p: &PathData| area(&extrude(p, FillRule::NonZero, outer, Extrude::default(), front()).unwrap()); + assert!(caps(&nested(false)) > caps(&nested(true)) + 1000.0); + assert!(inflate(&nested(false), FillRule::NonZero, outer, Inflate::default(), front()).is_ok()); +} From c348b384aa77f34e08fe69d087d87ebc57fd93ef Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:46:46 +0700 Subject: [PATCH 17/37] Fix #1723: a pattern's tile is drawn again when what its art shows changes A tile was kept while the pattern's own art, tile and tiling were the ones it was drawn from. Art that shows a symbol, is painted with another pattern or holds an image or type doesn't change when those do, so the pattern kept drawing the old symbol (in every renderer on the thread: the tiles are shared). Such a tile now also remembers the document's pattern and symbol art, image pixels and fonts, and is drawn again when they differ. A raster effect on an object painted with a pattern reads all of that too, as one on a symbol instance does. --- crates/render/src/fx.rs | 18 +++++++++-- crates/render/src/pattern.rs | 52 ++++++++++++++++++++++++++++--- crates/render/src/tests_memory.rs | 43 +++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 0109bb207..483ac77a7 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -532,7 +532,8 @@ impl Drawn { /// objects in it ([`Reads::of`]): which parts of [`Inputs`] can change what its rasters show. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Reads { - /// Pattern paints: the pattern definitions. + /// Pattern paints: the pattern definitions. Their art can hold anything, so content painted + /// with a pattern reads everything else too. patterns: bool, /// Symbol instances and type with inline graphics: the symbols' art. Symbol art can hold /// anything, so content that shows a symbol reads everything else too. @@ -550,14 +551,25 @@ pub(crate) struct Reads { impl Reads { /// What the content of `n` reads. pub(crate) fn of(n: &Node) -> Self { + Self::of_all(std::iter::once(n)) + } + + /// What the content of `nodes` reads together. A pattern's art can hold anything, as a + /// symbol's can, so content painted with a pattern reads everything else too (#1723). + pub(crate) fn of_all<'a>(nodes: impl Iterator) -> Self { let mut reads = Self::default(); - reads.add(n); - if reads.symbols { + nodes.for_each(|n| reads.add(n)); + if reads.symbols || reads.patterns { return Self { patterns: true, symbols: true, images: true, brushes: true, text: true }; } reads } + /// Does the content read anything outside its own nodes? + pub(crate) fn any(self) -> bool { + self != Self::default() + } + fn add(&mut self, n: &Node) { let pattern = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Pattern { .. }); n.walk(&mut |c| { diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index c61759dc2..711730a55 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -6,14 +6,16 @@ //! with the paint transform mapping pixels → pattern space → document. Tiles are cached per //! pattern name and raster scale (and ink plane, see [`crate::ink`]) within //! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was -//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision". +//! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and, +//! when its art shows more than itself (a symbol, another pattern, an image, type), while what +//! the document holds of those is what it held then ([`Deps`]). //! //! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with //! dimmed copies of it at the neighbouring tile positions and the tile edge. use std::cell::RefCell; use std::hash::{Hash, Hasher}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType}; use vectorcraft_doc::{Document, Node}; @@ -32,8 +34,46 @@ const MAX_TILE_PX: f64 = 2048.0; /// ([`raster_scale`]). type TileKey = (String, u64); +/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself: +/// the art of every pattern and symbol and the pixels of every image in the document, and the +/// fonts. Editing a symbol leaves the art of a pattern that shows it as it was, so the art alone +/// kept the tile from before (#1723). Held weakly: a weak reference keeps the allocation, so no +/// other value gets one of these addresses while the tile is kept. +struct Deps { + art: Vec>, + pixels: Vec>>, + fonts: u64, +} + +impl Deps { + fn art(doc: &Document) -> impl Iterator> { + doc.patterns.iter().flat_map(|p| &p.art).chain(doc.symbols.iter().map(|s| &s.art)) + } + + fn pixels(doc: &Document) -> impl Iterator>> { + doc.images.values().flat_map(|b| std::iter::once(&b.bytes).chain(&b.proxy)) + } + + fn of(doc: &Document) -> Self { + Self { + art: Self::art(doc).map(Arc::downgrade).collect(), + pixels: Self::pixels(doc).map(Arc::downgrade).collect(), + fonts: vectorcraft_text::FontDb::global().generation(), + } + } + + /// Does `doc` hold what the tile was drawn from? + fn current(&self, doc: &Document) -> bool { + self.fonts == vectorcraft_text::FontDb::global().generation() + && self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr)) + && self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr)) + } +} + struct TileEntry { art: Vec>, + /// `None` for art that shows only itself. + deps: Option, params: (Rect, TileType, Overlap), sx: f64, sy: f64, @@ -163,7 +203,10 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; let t = tiles.tick(); - tiles.get(&key, t).filter(|e| e.params == params && same_art(&e.art, &def.art)).map(|e| (e.pixmap.clone(), e.sx, e.sy)) + tiles + .get(&key, t) + .filter(|e| e.params == params && same_art(&e.art, &def.art) && e.deps.as_ref().is_none_or(|d| d.current(doc))) + .map(|e| (e.pixmap.clone(), e.sx, e.sy)) }); let (pm, sx, sy) = match hit { Some(h) => h, @@ -176,11 +219,12 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, }; let pm = Arc::new(pm); let (sx, sy) = (w as f64 / pw, h as f64 / ph); + let deps = crate::fx::Reads::of_all(def.art.iter().map(|a| &**a)).any().then(|| Deps::of(doc)); CACHE.with(|c| { let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); - tiles.insert(key, TileEntry { art: def.art.clone(), params, sx, sy, pixmap: pm.clone() }, bytes, t); + tiles.insert(key, TileEntry { art: def.art.clone(), deps, params, sx, sy, pixmap: pm.clone() }, bytes, t); }); (pm, sx, sy) } diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 031344ff8..5d1786012 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -940,6 +940,49 @@ fn rasters_draw_pattern_edits() { }); } +/// #1723: a pattern's tile is drawn again when what its art shows changes while the art itself +/// doesn't: a symbol an instance in it shows, or another pattern it is painted with. So is a +/// raster effect on an object painted with such a pattern. +#[test] +fn pattern_tiles_draw_edits_to_what_their_art_shows() { + let _fonts = fonts_steady(); + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_geom::shapes::rectangle; + let tile = |name: &str, art: Node| { + let mut def = PatternDef::new(name, vec![Arc::new(art)]); + def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + def + }; + // Pattern `Stamps` shows symbol `S`; pattern `Outer` is a square painted with pattern `Checks`. + let doc = |pattern: &str, effects: Vec| { + let mut d = Document::new(200.0, 100.0); + let art = symbol_art(&mut d); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let shown = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((10.0, 10.0)) }); + d.patterns.push(tile("Stamps", shown)); + d.patterns.push(checks(Color::rgb(1.0, 0.0, 0.0))); + let square = filled(&mut d, rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), pattern_paint("Checks"), vec![]); + d.patterns.push(tile("Outer", square)); + let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint(pattern), effects); + with_art(d, vec![painted]) + }; + let new_symbol = |d: &mut Document| { + let red = vectorcraft_doc::Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0); + let bar = Node::path(d.alloc_id(), rectangle(Rect::new(-8.0, -2.0, 8.0, 2.0)), red); + d.symbols[0].art = Arc::new(bar); + }; + let new_checks = |d: &mut Document| { + if let Some(def) = d.pattern_mut("Checks") { + def.art = checks(Color::rgb(0.0, 0.0, 1.0)).art; + } + }; + let blur = || vec![effect("blur.gaussian", serde_json::json!({"radius": 2.0}))]; + edit_draws_as_new(doc("Stamps", vec![]), new_symbol); + edit_draws_as_new(doc("Stamps", blur()), new_symbol); + edit_draws_as_new(doc("Outer", vec![]), new_checks); + edit_draws_as_new(doc("Outer", blur()), new_checks); +} + /// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop /// shadow around an instance of the other symbol), when a brush definition changes (a brushed /// stroke's drop shadow), and when an image's pixels change under the same key, as when a linked From 5688294d5603a45ec7f7d0f2794d0563503c3c4e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:48:39 +0700 Subject: [PATCH 18/37] Fix #1683: a template layer dims its images by Dim Images, not the whole layer The canvas faded a template layer as a whole by a fixed half and ignored its Dim Images option, so unchecking it or setting another percentage changed nothing, and the layer's paths were faded with its images. A template layer's images are now dimmed to its Dim Images percentage (50 % when Layer Options makes it a template), shown at full strength with the option off, and its vector art is drawn as it is. --- crates/render/src/lib.rs | 12 ++++++------ crates/render/src/tests_layeropts.rs | 28 ++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d293c6f84..a5676805a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -83,7 +83,8 @@ pub struct RenderOptions { pub artboards: bool, /// Objects not to draw (e.g. the object being edited by a live drag preview). pub hidden: Vec, - /// Draw template layers dimmed (50%). + /// Draw the layers as their Layer Options show them on screen: Preview off in outline, Dim + /// Images fading their images (a template layer's too), a template in Preview in Outline view. pub dim_templates: bool, /// Soft proof / separations preview (see [`proof`]). pub proof: Option, @@ -1088,8 +1089,8 @@ impl Renderer { return; } // Layer Options on screen: a layer whose Preview is off draws in outline, and Dim Images - // fades its images (exports and thumbnails, which leave templates out, ignore both). - // (A template is dimmed as a whole already.) + // fades its images, a template's too (exports and thumbnails, which leave templates out, + // ignore both). if let NodeKind::Layer { preview, dim_images, template, .. } = &n.kind && f.opts.dim_templates && !f.opts.skip_templates @@ -1104,7 +1105,7 @@ impl Renderer { let opts = RenderOptions { outline: true, ..f.opts.clone() }; return self.draw_node(ctx, &Frame { opts: &opts, ..*f }, n, force); } - if let Some(p) = dim_images.filter(|_| !template) { + if let Some(p) = *dim_images { let outer = self.dim_images; let dim = f32::from(p).clamp(0.0, 100.0) / 100.0; self.dim_images = Some(outer.map_or(dim, |o| o.min(dim))); @@ -1162,8 +1163,7 @@ impl Renderer { return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); } let outline = f.opts.outline; - let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates; - let opacity = if template_dim { self.opacity_of(n) * 0.5 } else { self.opacity_of(n) }; + let opacity = self.opacity_of(n); // Whether this container's children knock each other out. let knockout = !outline && n.knocks_out(self.knockout); let enclosing = std::mem::replace(&mut self.knockout, knockout); diff --git a/crates/render/src/tests_layeropts.rs b/crates/render/src/tests_layeropts.rs index a2864cc61..12aa36cfe 100644 --- a/crates/render/src/tests_layeropts.rs +++ b/crates/render/src/tests_layeropts.rs @@ -67,13 +67,37 @@ fn dim_images_fades_only_the_images() { assert_eq!(render(&d, true).pixel(65, 25), [255, 0, 0, 255], "exports are not dimmed"); } +/// #1683: a template layer dims its images by its Dim Images percentage and nothing else: with +/// the option off they show at full strength, and the layer's paths are never faded. +#[test] +fn a_template_layer_dims_its_images_by_dim_images() { + let template = |dim: Option| { + let d = doc(|k| { + if let NodeKind::Layer { template, dim_images, .. } = k { + (*template, *dim_images) = (true, dim); + } + }); + let img = render(&d, false); + (img.pixel(65, 25), img.pixel(25, 25)) + }; + let red = [255, 0, 0, 255]; + assert_eq!(template(None), (red, red), "Dim Images off: as it is"); + let (image, path) = template(Some(50)); + assert!(image[0] == 255 && (120..=135).contains(&image[1]) && image[1] == image[2], "half way to white: {image:?}"); + assert_eq!(path, red, "vector art stays"); + let (image, _) = template(Some(20)); + assert!((198..=210).contains(&image[1]) && image[1] == image[2], "a fifth of its strength: {image:?}"); + assert_eq!(template(Some(100)), (red, red)); +} + /// In Outline view a template layer still draws in Preview, dimmed, so its picture can be traced; /// the layer above it draws in outline (#1486). #[test] fn a_template_layer_stays_in_preview_in_outline_view() { let mut d = doc(|k| { - if let NodeKind::Layer { template, preview, .. } = k { - *template = true; + if let NodeKind::Layer { template, preview, dim_images, .. } = k { + // As Layer Options makes a template: its images dimmed to 50 %. + (*template, *dim_images) = (true, Some(50)); // Preview off doesn't put a template in outline either. *preview = false; } From 5ecd0451565381eee0a1025305ebba3aba44d4b4 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:41:13 +0700 Subject: [PATCH 19/37] Fix #1693: a browser download that can't be started fails the save it is for The web app's download callback returned nothing, so a save whose Blob, URL or anchor couldn't be made was reported as written: the document counted as saved, its recovery copy was dropped, and Save Changes closed it. The callback now returns the error, which fails the save, export or file command like a refused write on the desktop. A timer that can't be set after the download started is not a failure. --- apps/vectorcraft-web/src/web.rs | 10 +++--- crates/ui-egui/src/dialogs/save_options.rs | 5 ++- crates/ui-egui/src/dialogs/tests_package.rs | 7 ++-- .../ui-egui/src/dialogs/tests_save_for_web.rs | 5 ++- .../src/dialogs/tests_screen_assets.rs | 5 ++- crates/ui-egui/src/dialogs/tests_screens.rs | 5 ++- crates/ui-egui/src/io.rs | 34 +++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 ++- crates/ui-egui/src/panels/library_panel.rs | 9 +++-- crates/ui-egui/src/panels/links.rs | 5 +-- .../ui-egui/src/panels/tests_asset_export.rs | 5 ++- crates/ui-egui/src/shortcut_editor.rs | 5 +-- crates/ui-egui/src/tests_nativeoptions.rs | 5 ++- 13 files changed, 78 insertions(+), 26 deletions(-) diff --git a/apps/vectorcraft-web/src/web.rs b/apps/vectorcraft-web/src/web.rs index 500281876..e4e3bbd7f 100644 --- a/apps/vectorcraft-web/src/web.rs +++ b/apps/vectorcraft-web/src/web.rs @@ -434,11 +434,8 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Op ctx.request_repaint(); }); })), - download: Some(Box::new(|name: &str, bytes: &[u8]| { - if let Err(e) = download(name, bytes) { - log::error!("download of {name} failed: {e}"); - } - })), + // A download that couldn't be started fails the save or export it is for (#1693). + download: Some(Box::new(|name: &str, bytes: &[u8]| download(name, bytes).inspect_err(|e| log::error!("download of {name} failed: {e}")))), inbox: Some(inbox), recovery_store: Some(Arc::new(BrowserStore { locks })), // File → Print: the browser's print dialog. @@ -517,7 +514,8 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> { let revoke = wasm_bindgen::closure::Closure::once_into_js(move || { web_sys::Url::revoke_object_url(&url).ok(); }); - window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?; + // The download has started: a timer that can't be set leaves the URL allocated, no more. + let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000); Ok(()) } diff --git a/crates/ui-egui/src/dialogs/save_options.rs b/crates/ui-egui/src/dialogs/save_options.rs index 92958d8ae..0d5b5a06c 100644 --- a/crates/ui-egui/src/dialogs/save_options.rs +++ b/crates/ui-egui/src/dialogs/save_options.rs @@ -268,7 +268,10 @@ mod tests { let written = Written::default(); let w = written.clone(); let services = Services { - download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + download: Some(Box::new(move |name: &str, b: &[u8]| { + w.borrow_mut().push((name.to_string(), b.to_vec())); + Ok(()) + })), ..Default::default() }; (with_art(services), written) diff --git a/crates/ui-egui/src/dialogs/tests_package.rs b/crates/ui-egui/src/dialogs/tests_package.rs index f96073950..ecd26301b 100644 --- a/crates/ui-egui/src/dialogs/tests_package.rs +++ b/crates/ui-egui/src/dialogs/tests_package.rs @@ -95,7 +95,10 @@ fn the_web_downloads_a_zip() { let mut app = saved(&dir); let got: Rc>> = Rc::default(); let g = got.clone(); - app.services.download = Some(Box::new(move |name: &str, b: &[u8]| g.borrow_mut().push((name.to_string(), b.len())))); + app.services.download = Some(Box::new(move |name: &str, b: &[u8]| { + g.borrow_mut().push((name.to_string(), b.len())); + Ok(()) + })); app.run("ui.packageDialog", json!({})).unwrap(); assert!(!frame(&mut app).contains("Location:"), "no folder to choose"); confirm(&mut app).unwrap(); @@ -124,7 +127,7 @@ fn fonts_left_out_of_the_package_are_named() { assert!(detail.contains(gone) && !detail.contains("Source Sans 3"), "{detail}"); // The web's download says so too. app.ui.dialog = None; - app.services.download = Some(Box::new(|_: &str, _: &[u8]| {})); + app.services.download = Some(Box::new(|_: &str, _: &[u8]| Ok(()))); app.run("ui.packageDialog", json!({})).unwrap(); confirm(&mut app).unwrap(); assert!(app.ui.status.contains(gone), "{}", app.ui.status); diff --git a/crates/ui-egui/src/dialogs/tests_save_for_web.rs b/crates/ui-egui/src/dialogs/tests_save_for_web.rs index 164087a95..b13d36f2c 100644 --- a/crates/ui-egui/src/dialogs/tests_save_for_web.rs +++ b/crates/ui-egui/src/dialogs/tests_save_for_web.rs @@ -24,7 +24,10 @@ fn app(dir: &std::path::Path, web: bool) -> (VectorcraftApp, Log) { ..Default::default() }; if web { - services.download = Some(Box::new(move |name: &str, b: &[u8]| l2.borrow_mut().push(format!("download {name} {}", b.len())))); + services.download = Some(Box::new(move |name: &str, b: &[u8]| { + l2.borrow_mut().push(format!("download {name} {}", b.len())); + Ok(()) + })); } let mut app = VectorcraftApp::new(Session::new(), services); app.run("file.new", json!({"width": 60, "height": 40})).unwrap(); diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index b052d1651..9274b5224 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) { let l = log.clone(); let mut services = Services::default(); if web { - services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { + l.borrow_mut().push((name.to_string(), bytes.to_vec())); + Ok(()) + })); } else { services.reveal = Some(Box::new(move |path: &str| { l.borrow_mut().push((path.to_string(), vec![])); diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 506001abf..4341415cb 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -21,7 +21,10 @@ fn app(artboards: usize, web: bool) -> (VectorcraftApp, Log) { let l = log.clone(); let mut services = Services::default(); if web { - services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { + l.borrow_mut().push((name.to_string(), bytes.to_vec())); + Ok(()) + })); } else { services.reveal = Some(Box::new(move |path: &str| { l.borrow_mut().push((path.to_string(), vec![])); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 314462c3c..66198372f 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -239,8 +239,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option) -> Resu /// Web: download `bytes` under `path`'s file name; desktop: write them to `path`. pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> { if let Some(dl) = services.download.as_mut() { - dl(&fileio::file_name(path), bytes); - return Ok(()); + return dl(&fileio::file_name(path), bytes); } services.write.as_mut().ok_or("no file writer")?(path, bytes) } @@ -768,7 +767,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> } else if let Some(download) = app.services.download.as_mut() { for (name, data) in &returned { let bytes = vectorcraft_format::base64_decode(data).ok_or("the export returned unreadable data")?; - download(name, &bytes); + download(name, &bytes)?; } let what = match returned.as_slice() { [(name, _)] => (*name).to_string(), @@ -830,6 +829,35 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// #1693: a browser download that can't be started is a failed save: the document stays + /// modified and without a file, and Save Changes doesn't close it. + #[test] + fn a_download_that_cannot_start_leaves_the_document_unsaved_and_open() { + let started = Rc::new(RefCell::new(false)); + let ok = started.clone(); + let download: crate::DownloadFn = Box::new(move |_: &str, _: &[u8]| if *ok.borrow() { Ok(()) } else { Err("the browser refused the download".into()) }); + let mut app = VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() }); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let unsaved = |app: &VectorcraftApp| { + let st = app.session.doc().unwrap(); + (st.is_dirty(), st.path.clone()) + }; + let e = save(&mut app, SaveMode::Save, &json!({"path": "Poster.vectorcraft"}), false).unwrap_err(); + assert!(e.contains("the browser refused the download"), "{e}"); + assert_eq!(unsaved(&app), (true, None)); + // Save Changes › Save: not saved, so not closed. + let (i, uid) = (app.session.active_index().unwrap(), app.session.doc().unwrap().uid); + app.ui.dialog = Some(crate::state::Dialog::new(crate::unsaved::KIND, json!({"index": i, "uid": uid, "name": "Poster", "then": "close"}))); + app.session.doc_mut().unwrap().path = Some("Poster.vectorcraft".into()); + assert!(crate::unsaved::confirm(&mut app).is_err()); + assert_eq!((app.session.documents().len(), app.session.doc().unwrap().is_dirty()), (1, true)); + // Once the download starts, the save counts. + *started.borrow_mut() = true; + save(&mut app, SaveMode::Save, &json!({}), false).unwrap(); + assert!(!app.session.doc().unwrap().is_dirty()); + } + fn bytes_of(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Vec { let v = app.session.execute(cmd, &p).unwrap(); vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e3e953cc9..aae24fbe5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -188,7 +188,9 @@ pub type PickSave = Box Option>; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type Inbox = Arc)>>>; -pub type DownloadFn = Box; +/// Starts a browser download of the bytes under a file name; an error when it couldn't be started +/// (the save or export then fails as a refused write does: #1693). +pub type DownloadFn = Box Result<(), String>>; /// Opens a URL in the system browser. pub type OpenUrlFn = Box; diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index dc2f1aab7..00ca76fbb 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -646,8 +646,13 @@ mod tests { // The web downloads the file under that name. let downloads = std::rc::Rc::new(std::cell::RefCell::new(vec![])); let dl = downloads.clone(); - let services = - crate::Services { download: Some(Box::new(move |name: &str, _: &[u8]| dl.borrow_mut().push(name.to_string()))), ..Default::default() }; + let services = crate::Services { + download: Some(Box::new(move |name: &str, _: &[u8]| { + dl.borrow_mut().push(name.to_string()); + Ok(()) + })), + ..Default::default() + }; let mut web = VectorcraftApp::new(Session::new(), services); web.run("file.new", json!({"width": 100, "height": 100})).unwrap(); web.run("ui.saveSwatchLibrary", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 5250c8542..6031e6efa 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -155,11 +155,12 @@ pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) { /// Write embedded image `id` to a picked file (the web downloads it) and link it there. pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) { - if let Some(dl) = app.services.download.as_mut() { + if app.services.download.is_some() { if let Ok(v) = app.session.execute("links.unembed", &json!({ "id": id })) && let (Some(name), Some(bytes)) = (v["name"].as_str(), v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode)) + && let Err(e) = crate::io::write_to(&mut app.services, name, &bytes) { - dl(name, &bytes); + app.status(e); } return; } diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index 9b978e833..ace9b6cc2 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -21,7 +21,10 @@ fn app(web: bool) -> (VectorcraftApp, Log, [u64; 2]) { let l = log.clone(); let mut services = Services::default(); if web { - services.download = Some(Box::new(move |name: &str, bytes: &[u8]| l.borrow_mut().push((name.to_string(), bytes.to_vec())))); + services.download = Some(Box::new(move |name: &str, bytes: &[u8]| { + l.borrow_mut().push((name.to_string(), bytes.to_vec())); + Ok(()) + })); } let mut app = VectorcraftApp::new(Session::new(), services); app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 612f1370f..7602867b7 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -485,10 +485,7 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option Err("cancelled".into()), None => match app.services.download.as_mut() { - Some(dl) => { - dl("VectorCraft Shortcuts.json", &bytes); - Ok(Value::Null) - } + Some(dl) => dl("VectorCraft Shortcuts.json", &bytes).map(|()| Value::Null), None => Ok(v), }, } diff --git a/crates/ui-egui/src/tests_nativeoptions.rs b/crates/ui-egui/src/tests_nativeoptions.rs index aec94be4b..59b8c91de 100644 --- a/crates/ui-egui/src/tests_nativeoptions.rs +++ b/crates/ui-egui/src/tests_nativeoptions.rs @@ -86,7 +86,10 @@ fn the_web_save_as_dialog_shows_the_native_options() { let written = crate::tests_svg::Written::default(); let w = written.clone(); let services = crate::Services { - download: Some(Box::new(move |name: &str, b: &[u8]| w.borrow_mut().push((name.to_string(), b.to_vec())))), + download: Some(Box::new(move |name: &str, b: &[u8]| { + w.borrow_mut().push((name.to_string(), b.to_vec())); + Ok(()) + })), ..Default::default() }; let mut app = crate::VectorcraftApp::new(vectorcraft_engine::Session::new(), services); From 8f8d49110f193803c4eefe9de0fd1e1a711734af Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 08:42:22 +0700 Subject: [PATCH 20/37] Fix #1694: a file that finishes opening waits while a dialog is open A document opened off the UI thread became the active one as soon as it was ready, also under an open dialog, whose OK acts on the active document: Save As options confirmed after another file had finished opening wrote that file's art under the first document's name. Files that are ready now wait until the dialog is closed. --- crates/ui-egui/src/opening.rs | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 99fb9c233..62f4ef081 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -68,8 +68,14 @@ pub fn start(app: &mut VectorcraftApp, path: &str, bytes: Vec) -> Result Date: Wed, 14 Oct 2026 08:44:42 +0700 Subject: [PATCH 21/37] Fix #1685: a save or export that fails is reported in a dialog that stays A save that couldn't write its file (a read-only folder, a file another program holds open, a full disk) was only reported in the status bar, a line the next message replaces; a background save's failure arrived there some time after the user had moved on. Save, Save As, Save a Copy, Save as Template, Export As and the commands that write one file now also say it in a dialog naming the file and the reason, which stays until it is closed. The document stays modified and keeps the file it had. A message that arrives while another dialog is open waits for it to close instead of replacing it. An agent's request gets the error as its reply, without a dialog; a failed recovery write stays in the status bar. --- crates/ui-egui/src/dialogs/confirm.rs | 21 +++++- crates/ui-egui/src/dialogs/mod.rs | 1 + crates/ui-egui/src/i18n/cs.tsv | 2 + crates/ui-egui/src/i18n/de.tsv | 2 + crates/ui-egui/src/i18n/es.tsv | 2 + crates/ui-egui/src/i18n/fr.tsv | 2 + crates/ui-egui/src/i18n/hu.tsv | 2 + crates/ui-egui/src/i18n/it.tsv | 2 + crates/ui-egui/src/i18n/ja.tsv | 2 + crates/ui-egui/src/i18n/ko.tsv | 2 + crates/ui-egui/src/i18n/pl.tsv | 2 + crates/ui-egui/src/i18n/pt-br.tsv | 2 + crates/ui-egui/src/i18n/ru.tsv | 2 + crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/i18n/vi.tsv | 2 + crates/ui-egui/src/i18n/zh-hans.tsv | 2 + crates/ui-egui/src/i18n/zh-hant.tsv | 2 + crates/ui-egui/src/io.rs | 94 +++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/panels/links.rs | 4 +- crates/ui-egui/src/state.rs | 10 +++ 21 files changed, 154 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/dialogs/confirm.rs b/crates/ui-egui/src/dialogs/confirm.rs index ba1668748..555a1b83e 100644 --- a/crates/ui-egui/src/dialogs/confirm.rs +++ b/crates/ui-egui/src/dialogs/confirm.rs @@ -41,10 +41,27 @@ pub const MESSAGE: &str = "message"; pub(super) const MESSAGE_SPEC: DialogSpec = DialogSpec { ok: None, ..SPEC }; +/// Most messages kept for later while a dialog is open ([`tell`]). +const MAX_WAITING: usize = 16; + /// Tell the user `message` (with an optional `detail` line), both in the UI language, in a dialog -/// they close. +/// they close. While another dialog is open (the message comes from work in the background) it +/// waits its turn instead of replacing it: neither what the user was doing there nor the message +/// is lost. pub fn tell(app: &mut VectorcraftApp, message: &str, detail: &str) { - app.ui.dialog = Some(Dialog::new(MESSAGE, json!({"message": message, "detail": detail}))); + let d = Dialog::new(MESSAGE, json!({"message": message, "detail": detail})); + if app.ui.dialog.is_none() { + app.ui.dialog = Some(d); + } else if app.ui.told.len() < MAX_WAITING && !app.ui.dialog.iter().chain(&app.ui.told).any(|t| t.kind == MESSAGE && t.fields == d.fields) { + app.ui.told.push(d); + } +} + +/// Show the next message that waited for a dialog to close (each frame). +pub(super) fn show_waiting(app: &mut VectorcraftApp) { + if app.ui.dialog.is_none() && !app.ui.told.is_empty() { + app.ui.dialog = Some(app.ui.told.remove(0)); + } } /// Closes before running, so a dialog the command opens stays open. diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 40d1ca191..5deb182a9 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -366,6 +366,7 @@ pub(crate) fn settle(app: &mut VectorcraftApp) { pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { about::show(app, ctx); settle(app); + confirm::show_waiting(app); // The kind of dialog shown last frame: a different one (or none) means this one just opened, and // its first field is to take the keyboard focus (`focus_id`, until a field takes it). let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending")); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 4b35d2628..db12bd3af 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3508,6 +3508,8 @@ effect Regular Pravidelný Serif / Mincho Patkové / mincho Changes made since it was last saved will be lost. Změny provedené od posledního uložení budou ztraceny. Can't open “{name}”. „{name}“ nelze otevřít. + Can't save “{name}”. „{name}“ nelze uložit. + Can't export “{name}”. „{name}“ nelze exportovat. Downloaded {what} Staženo: {what} Exported {count} file(s) to {folder} Exportováno souborů: {count} do {folder} Exported {what} Exportováno: {what} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 51b06badb..cf7bb1be4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -754,6 +754,8 @@ Do Not Move Nicht verschieben Do you want to save the changes you made to “{name}”? Möchten Sie die Änderungen an „{name}“ speichern? Can't open “{name}”. „{name}“ kann nicht geöffnet werden. + Can't save “{name}”. „{name}“ kann nicht gespeichert werden. + Can't export “{name}”. „{name}“ kann nicht exportiert werden. Document Dokument Document Color Mode Dokumentfarbmodus Document Info Dokumentinformationen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c9dba8677..b04fc1f2c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -768,6 +768,8 @@ Do Not Move No mover Do you want to save the changes you made to “{name}”? ¿Quieres guardar los cambios hechos en “{name}”? Can't open “{name}”. No se puede abrir «{name}». + Can't save “{name}”. No se puede guardar «{name}». + Can't export “{name}”. No se puede exportar «{name}». Document Documento Document Color Mode Modo de color del documento Document Info Información del documento diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index cccd38e71..4176af3ae 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -770,6 +770,8 @@ Do Not Move Ne pas déplacer Do you want to save the changes you made to “{name}”? Voulez-vous enregistrer les modifications apportées à « {name} » ? Can't open “{name}”. Impossible d’ouvrir « {name} ». + Can't save “{name}”. Impossible d’enregistrer « {name} ». + Can't export “{name}”. Impossible d’exporter « {name} ». Document Document Document Color Mode Mode colorimétrique du document Document Info Informations sur le document diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 89d39aae0..ec2e69fd3 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -485,6 +485,8 @@ Calligraphic Kalligrafikus Calligraphic Brush Options Kalligrafikus ecset beállításai Can't open “{name}”. Nem nyitható meg: „{name}”. + Can't save “{name}”. Nem menthető: „{name}”. + Can't export “{name}”. Nem exportálható: „{name}”. Can't print these settings: see the Summary Ezekkel a beállításokkal nem lehet nyomtatni: lásd az Összegzést Cancel Mégse Cancel Key Object Kulcsobjektum megszüntetése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 494252d06..b414c8153 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -768,6 +768,8 @@ Do Not Move Non spostare Do you want to save the changes you made to “{name}”? Vuoi salvare le modifiche apportate a “{name}”? Can't open “{name}”. Impossibile aprire «{name}». + Can't save “{name}”. Impossibile salvare «{name}». + Can't export “{name}”. Impossibile esportare «{name}». Document Documento Document Color Mode Metodo colore documento Document Info Informazioni documento diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index edb50b54f..90b7b3c69 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1223,6 +1223,8 @@ Divide Vertically Into 垂直方向に分割 Do you want to save the changes you made to “{name}”? 「{name}」への変更を保存しますか? Can't open “{name}”. 「{name}」を開けません。 + Can't save “{name}”. 「{name}」を保存できません。 + Can't export “{name}”. 「{name}」を書き出せません。 Document ドキュメント Document Ink Options ドキュメントのインキオプション Document JSON ドキュメント JSON diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 14070de7a..02b1d58b1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1218,6 +1218,8 @@ Divide Vertically Into 세로로 나누기 Do you want to save the changes you made to “{name}”? “{name}”의 변경 내용을 저장하시겠습니까? Can't open “{name}”. “{name}”을(를) 열 수 없습니다. + Can't save “{name}”. “{name}”을(를) 저장할 수 없습니다. + Can't export “{name}”. “{name}”을(를) 내보낼 수 없습니다. Document 문서 Document Ink Options 문서 잉크 옵션 Document JSON 문서 JSON diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 869e2a9bd..c98e48a5f 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3391,6 +3391,8 @@ effect Regular Normalny Serif / Mincho Szeryfowe / mincho Changes made since it was last saved will be lost. Zmiany wprowadzone od ostatniego zapisu zostaną utracone. Can't open “{name}”. Nie można otworzyć „{name}”. + Can't save “{name}”. Nie można zapisać „{name}”. + Can't export “{name}”. Nie można wyeksportować „{name}”. Downloaded {what} Pobrano {what} Exported {count} file(s) to {folder} Wyeksportowane pliki do {folder}: {count} Exported {what} Wyeksportowano {what} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index e5b3e964f..c39b61c7b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -692,6 +692,8 @@ Divide Vertically Into Dividir Verticalmente Em Do you want to save the changes you made to “{name}”? Deseja salvar as alterações feitas em “{name}”? Can't open “{name}”. Não é possível abrir “{name}”. + Can't save “{name}”. Não é possível salvar “{name}”. + Can't export “{name}”. Não é possível exportar “{name}”. Document Documento Document Color Mode Modo de Cor do Documento Document Info Informações do Documento diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 1b92d00d0..e4a9a39f5 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -766,6 +766,8 @@ Do Not Move Не перемещать Do you want to save the changes you made to “{name}”? Сохранить изменения, внесённые в «{name}»? Can't open “{name}”. Не удаётся открыть «{name}». + Can't save “{name}”. Не удаётся сохранить «{name}». + Can't export “{name}”. Не удаётся экспортировать «{name}». Document Документ Document Color Mode Цветовой режим документа Document Info Сведения о документе diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 919997620..268d19331 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -762,6 +762,8 @@ Do Not Move Не переміщати Do you want to save the changes you made to “{name}”? Зберегти зміни, внесені до «{name}»? Can't open “{name}”. Не вдається відкрити «{name}». + Can't save “{name}”. Не вдається зберегти «{name}». + Can't export “{name}”. Не вдається експортувати «{name}». Document Документ Document Color Mode Колірний режим документа Document Info Відомості про документ diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 44dfae0a3..63cf26f68 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -401,6 +401,8 @@ Calligraphic Thư pháp Calligraphic Brush Options Tùy chọn cọ thư pháp Can't open “{name}”. Không thể mở “{name}”. + Can't save “{name}”. Không thể lưu “{name}”. + Can't export “{name}”. Không thể xuất “{name}”. Can't print these settings: see the Summary Không thể in các cài đặt này: xem Tóm tắt Cancel Hủy Cancel Key Object Hủy đối tượng chính diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0ed1c6338..6ac0c6a79 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -723,6 +723,8 @@ Do Not Move 不移动 Do you want to save the changes you made to “{name}”? 要存储对“{name}”所做的更改吗? Can't open “{name}”. 无法打开“{name}”。 + Can't save “{name}”. 无法保存“{name}”。 + Can't export “{name}”. 无法导出“{name}”。 Document 文档 Document Color Mode 文档颜色模式 Document Info 文档信息 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 70e2d1a09..6b769b8a4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -741,6 +741,8 @@ Do Not Move 不移動 Do you want to save the changes you made to “{name}”? 要儲存對「{name}」所做的變更嗎? Can't open “{name}”. 無法開啟「{name}」。 + Can't save “{name}”. 無法儲存「{name}」。 + Can't export “{name}”. 無法匯出「{name}」。 Document 文件 Document Color Mode 文件色彩模式 Document Info 文件資訊 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 66198372f..d5dd5b5de 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -167,6 +167,29 @@ pub fn report_open_error(app: &mut VectorcraftApp, name: &str, e: &str) { dialogs::confirm::tell(app, &message, &crate::i18n::message(crate::i18n::current(), e)); } +/// A save or an export that didn't write `path`: say so in a dialog that names the file and the +/// reason and stays until it is closed, as an editor does (#1685). The status bar's line, which +/// the caller's error becomes, is replaced by the next message, and a document that wasn't saved +/// must not look saved: it stays modified, and what was at `path` is as it was. Not for a request +/// an agent sent and waits on: the error is its reply. +pub(crate) fn report_write_error(app: &mut VectorcraftApp, export: bool, path: &str, e: &str) { + if app.ui.answering_control { + return; + } + let name = fileio::file_name(path); + let message = if export { tl!("Can't export “{name}”.") } else { tl!("Can't save “{name}”.") }; + dialogs::confirm::tell(app, &crate::i18n::fmt(message, &[("name", &name)]), &crate::i18n::message(crate::i18n::current(), e)); +} + +/// [`write_to`], a failure reported ([`report_write_error`]). +pub(crate) fn write_reporting(app: &mut VectorcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { + let r = write_to(&mut app.services, path, bytes); + if let Err(e) = &r { + report_write_error(app, false, path, e); + } + r +} + /// [`open_path`] for a file the user asked for, a failure reported ([`report_open_error`]). On /// the desktop a document is decoded off the UI thread ([`crate::opening`]): the result is then /// `{background: true, status}`, and the document opens in its tab (or the failure is reported) @@ -259,7 +282,7 @@ pub(crate) fn write_named(app: &mut VectorcraftApp, path: Option, name: pick_path(app, &FilePick { name: name.to_string(), folder, ..Default::default() })? } }; - write_to(&mut app.services, &path, bytes)?; + write_reporting(app, &path, bytes)?; Ok(path) } @@ -515,8 +538,10 @@ fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result } .map_err(|e| e.to_string()) }; + let failed = target.clone().unwrap_or_default(); let then = move |app: &mut VectorcraftApp, r: Result| { - let r = r?; + // Not written: the document keeps its file, format and modified state, and the user is told. + let r = r.inspect_err(|e| report_write_error(app, false, &failed, e))?; // The document may have closed meanwhile: the file is written all the same. done.complete(&mut app.session, uid); let path = r["path"].as_str().unwrap_or_default().to_string(); @@ -601,8 +626,9 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option format!("{} files", files.len()), None => r["path"].as_str().unwrap_or_default().to_string(), @@ -672,7 +698,7 @@ pub(crate) fn run_to_file(app: &mut VectorcraftApp, id: &str, ext: &str, mut par Some(p) => p, None => target_path(app, None, ext)?, }; - write_to(&mut app.services, &path, &bytes)?; + write_reporting(app, &path, &bytes)?; Ok((path, v)) } @@ -829,13 +855,69 @@ mod tests { assert!(app.ui.dialog.is_none()); } + /// #1685: a save or an export that can't write its file says so in a dialog that names the + /// file and the reason and stays until it is closed; the document stays modified and keeps + /// the file it had. An agent's request only gets the error, and a message that arrives while + /// another dialog is open waits for it to close. + #[test] + fn a_save_that_cannot_write_is_reported_in_a_dialog_that_stays() { + let refuse: crate::WriteFn = Box::new(|p: &str, _: &[u8]| Err(format!("{p}: Access is denied. (os error 5)"))); + let mut app = VectorcraftApp::new(Session::new(), Services { write: Some(refuse), ..Default::default() }); + app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let unsaved = |app: &VectorcraftApp| { + let st = app.session.doc().unwrap(); + (st.is_dirty(), st.path.clone()) + }; + for (command, path, message) in [ + ("file.save", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."), + ("file.saveAs", "/docs/Poster.vectorcraft", "Can't save “Poster.vectorcraft”."), + ("file.saveCopy", "/docs/Copy.vectorcraft", "Can't save “Copy.vectorcraft”."), + ("file.exportAs", "/docs/Poster.png", "Can't export “Poster.png”."), + ("file.export.pdf", "/docs/Poster.pdf", "Can't save “Poster.pdf”."), + ] { + let e = app.run(command, json!({"path": path})).unwrap_err(); + assert!(e.contains("Access is denied"), "{command}: {e}"); + let d = app.ui.dialog.take().unwrap_or_else(|| panic!("{command}: a dialog says so")); + assert_eq!((d.kind.as_str(), d.str("message").as_str()), (dialogs::confirm::MESSAGE, message), "{command}"); + assert_eq!(d.str("detail"), format!("{path}: Access is denied. (os error 5)"), "{command}"); + assert_eq!(unsaved(&app), (true, None), "{command}"); + } + // An agent waiting on its request gets the error as its reply, and no dialog. + app.ui.answering_control = true; + assert!(app.run("file.save", json!({"path": "/docs/Poster.vectorcraft"})).is_err()); + app.ui.answering_control = false; + assert!(app.ui.dialog.is_none() && app.ui.told.is_empty()); + // Under another dialog (a background save that fails later) the message waits its turn, + // once, and is drawn when that dialog is closed. + dialogs::confirm::ask(&mut app, "Delete the swatch?", "", "edit.undo", json!({})); + for _ in 0..2 { + report_write_error(&mut app, false, "/docs/Poster.vectorcraft", "the disk is full"); + } + assert_eq!((app.ui.dialog.as_ref().map(|d| d.kind.as_str()), app.ui.told.len()), (Some(dialogs::confirm::KIND), 1)); + app.ui.dialog = None; + for _ in 0..2 { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(&mut app, ui.ctx())); + out.textures_delta.clear(); + } + let d = app.ui.dialog.as_ref().expect("the message is shown, and stays"); + assert_eq!( + (d.kind.as_str(), d.str("message").as_str(), d.str("detail").as_str()), + (dialogs::confirm::MESSAGE, "Can't save “Poster.vectorcraft”.", "the disk is full") + ); + assert!(app.ui.told.is_empty()); + } + /// #1693: a browser download that can't be started is a failed save: the document stays /// modified and without a file, and Save Changes doesn't close it. #[test] fn a_download_that_cannot_start_leaves_the_document_unsaved_and_open() { let started = Rc::new(RefCell::new(false)); let ok = started.clone(); - let download: crate::DownloadFn = Box::new(move |_: &str, _: &[u8]| if *ok.borrow() { Ok(()) } else { Err("the browser refused the download".into()) }); + let download: crate::DownloadFn = + Box::new(move |_: &str, _: &[u8]| if *ok.borrow() { Ok(()) } else { Err("the browser refused the download".into()) }); let mut app = VectorcraftApp::new(Session::new(), Services { download: Some(download), ..Default::default() }); app.run("file.new", json!({"width": 100, "height": 80})).unwrap(); app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aae24fbe5..012decbf2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -780,7 +780,10 @@ impl VectorcraftApp { let reply = req.reply.clone(); // Guarded per request: a panic must not drop the channel (taken out of `self` above). let roots = self.automation_roots.clone(); - let outcome = match file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))) { + self.ui.answering_control = true; + let answer = file_access::confine(roots.as_ref(), || vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req))); + self.ui.answering_control = false; + let outcome = match answer { Ok(outcome) => outcome, Err(msg) => { self.picks.recover(); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 6031e6efa..329d9d885 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -158,9 +158,9 @@ pub(crate) fn unembed(app: &mut VectorcraftApp, id: u64, name: &str) { if app.services.download.is_some() { if let Ok(v) = app.session.execute("links.unembed", &json!({ "id": id })) && let (Some(name), Some(bytes)) = (v["name"].as_str(), v["dataBase64"].as_str().and_then(vectorcraft_format::base64_decode)) - && let Err(e) = crate::io::write_to(&mut app.services, name, &bytes) { - app.status(e); + // A download that can't start is said like any file that can't be written. + let _ = crate::io::write_reporting(app, name, &bytes); } return; } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e4eb10a0c..1fc6f089f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -306,6 +306,14 @@ pub struct UiState { pub dock: bool, pub view: ViewFlags, pub dialog: Option, + /// Messages to show in a dialog once the open one is closed (`dialogs::confirm::tell`), in + /// the order they came. + #[serde(skip)] + pub told: Vec, + /// A control request is being answered: its caller gets what fails as its reply, so no + /// dialog says it too (`io::report_write_error`). + #[serde(skip)] + pub answering_control: bool, /// Toolbar group whose flyout is open. pub flyout: Option, /// Last tool shown for each toolbar group (flyout selection sticks). @@ -681,6 +689,8 @@ impl Default for UiState { dock: true, view: ViewFlags::default(), dialog: None, + told: vec![], + answering_control: false, flyout: None, group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(), status: String::new(), From 3ae71c16ae52f1e5d3a7d91b391ccf9a042ab507 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 09:40:23 +0700 Subject: [PATCH 22/37] Importers: an SVG's linked files are read within the automation roots again The SVG importer runs on a thread of its own since #1852 (for its stack), and the automation roots in force on the thread that opened the file didn't hold there: an agent confined to a read root could have the app read any file an SVG links to. The reads now run in the scope of the thread that asked. --- crates/engine/src/cmd/fileio/load.rs | 15 ++++++++++----- crates/engine/src/tests_linkfiles.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 891a43070..da4cb9ad6 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -273,12 +273,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result /// own): rasters stay linked, SVG files become art, missing ones placeholders (see /// [`vectorcraft_svg::ImportOptions`]). pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec)> { + // The importer reads on a thread of its own (for its stack): what may be read is what may be + // read here. + let scope = crate::file_access::ScopeHandle::capture(); let read = |path: &str| { - // Only files (not folders or devices) are read. - file_stamp(path)?; - let bytes = read_file(path).ok()?; - let link = crate::cmd::links::link_info(path, &bytes); - Some((bytes, link)) + scope.clone().run(|| { + // Only files (not folders or devices) are read. + file_stamp(path)?; + let bytes = read_file(path).ok()?; + let link = crate::cmd::links::link_info(path, &bytes); + Some((bytes, link)) + }) }; vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err) } diff --git a/crates/engine/src/tests_linkfiles.rs b/crates/engine/src/tests_linkfiles.rs index a0e3a430f..1c4cc5306 100644 --- a/crates/engine/src/tests_linkfiles.rs +++ b/crates/engine/src/tests_linkfiles.rs @@ -314,6 +314,32 @@ fn a_copy_stays_inside_the_write_root() { let _ = std::fs::remove_dir_all(&root); } +/// An SVG's linked files are read on the importer's own thread (it needs a deep stack): the roots +/// in force on the thread that opens the SVG hold there too. +#[test] +fn an_svg_reads_its_links_within_the_read_root() { + let root = folder("svg-roots"); + for dir in ["in", "out"] { + write(&root.join(dir).join("dot.png"), &png(4, 4, RED)); + } + // What came of an SVG linking to the picture in `dir`: the pictures read, and the warnings. + let import = |dir: &str| { + let href = root.join(dir).join("dot.png").to_string_lossy().replace('\\', "/"); + let svg = + format!(""); + let (doc, warnings) = crate::cmd::fileio::import_svg(&svg, None).unwrap(); + (doc.images.len(), warnings) + }; + let (inside, outside) = (import("in"), import("out")); + assert_eq!(inside, outside, "both are read with no roots in force"); + let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap(); + crate::file_access::confine(roots.as_ref(), || { + assert_eq!(import("in"), inside, "inside the read root: read as before"); + assert_ne!(import("out"), outside, "outside the read root: not read"); + }); + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn junk_search_params_never_fail_internally() { let root = folder("junk"); From 17d2493616637925cc3ad8e53876b6522c0f0e52 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 09:40:58 +0700 Subject: [PATCH 23/37] Importers: PDF nesting inside compressed streams no longer crashes (hayro 0.8) hayro-syntax 0.8.0 reads arrays and dictionaries 64 deep and no deeper wherever they are (the file's text, object streams, the content of pages, forms, patterns, glyphs and masks), walks the page tree without recursion and follows a chain of object streams once: what 0.7.2 overflowed the stack on. hayro-interpret 0.8.0 comes with it (a new device interface: the importer's device takes it as before). Our raw pre-scan of the file's text and page content goes: the reader's own cap covers more, and the scan miscounted the bytes of unfiltered binary streams. What 0.8.0 still builds by recursion with no cap is refused when the file opens: functions and colour spaces built on themselves or on one another more than 16 deep, and calculator functions whose procedures nest more than 64 deep. The import runs on a 16 MB thread, as the SVG importer does. hayro-syntax 0.8 turns on flate2's zlib-rs backend, which the whole app then deflates with: a compressed native file is the same gzip stream as before (header and data), but its deflate blocks now depend on how the data is fed, so the streaming test checks the header and the unpacked data instead of the bytes. --- Cargo.lock | 46 ++-- crates/engine/tests/import_fuzz.rs | 300 +++++++++++++++++++++++++- crates/format/src/stream.rs | 3 +- crates/format/tests/streaming.rs | 14 +- crates/pdf/Cargo.toml | 4 +- crates/pdf/src/import.rs | 178 +++++++++------ crates/pdf/src/import_color.rs | 2 +- crates/pdf/src/import_scan.rs | 3 +- crates/pdf/src/import_shading.rs | 4 +- crates/pdf/src/pages.rs | 162 +++++++++----- crates/pdf/src/tests.rs | 43 ++++ crates/pdf/src/tests_import_layers.rs | 5 +- 12 files changed, 606 insertions(+), 158 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7d06a33c5..1303758db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "fearless_simd" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1760,9 +1766,9 @@ dependencies = [ [[package]] name = "hayro-ccitt" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" +checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad" [[package]] name = "hayro-cmap" @@ -1776,9 +1782,9 @@ dependencies = [ [[package]] name = "hayro-interpret" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d" +checksum = "ac9df074ff163a63864cec9b0bf640ccbff8abf4e44481df7369ce0ac172ffbe" dependencies = [ "bitflags 2.13.2", "hayro-cmap", @@ -1788,28 +1794,28 @@ dependencies = [ "phf", "rustc-hash 2.1.3", "siphasher", - "skrifa 0.42.1", + "skrifa 0.46.2", "smallvec", "yoke", ] [[package]] name = "hayro-jbig2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" dependencies = [ - "fearless_simd", + "fearless_simd 1.1.0", "hayro-ccitt", ] [[package]] name = "hayro-jpeg2000" -version = "0.3.5" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +checksum = "122c5f834c37a2260b9c851bb8be685cf539b2a8b94b704a931ef7d2525d5835" dependencies = [ - "fearless_simd", + "fearless_simd 1.1.0", ] [[package]] @@ -1820,9 +1826,9 @@ checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa" [[package]] name = "hayro-syntax" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +checksum = "6cdc818d8d265dcb42081d488f7599df4f73fb79e9d347ecedcd3c23809d2026" dependencies = [ "flate2", "hayro-ccitt", @@ -3833,6 +3839,16 @@ dependencies = [ "read-fonts 0.41.0", ] +[[package]] +name = "skrifa" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4febebda507fb889c545a37a135517769d1391941013561292897961d1887d6" +dependencies = [ + "bytemuck", + "read-fonts 0.43.3", +] + [[package]] name = "skrifa" version = "0.47.0" @@ -4986,7 +5002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.4.1", "guillotiere", "log", "peniko", @@ -5001,7 +5017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.4.1", "guillotiere", "log", "peniko", diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 84a9cc854..bd79a7b2c 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -538,17 +538,29 @@ proptest! { // ---------- PDF ---------- fn handmade_pdf(content: &str, resources: &str, media: &str) -> Vec { - let objs = [ + handmade_pdf_with(content, resources, media, &[]) +} + +/// The same, followed by the objects `more` (numbered from 5). +fn handmade_pdf_with(content: &str, resources: &str, media: &str, more: &[Vec]) -> Vec { + let page = [ "<< /Type /Catalog /Pages 2 0 R >>".to_string(), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources {resources} >>"), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1), ]; + pdf_of(&page.into_iter().map(String::into_bytes).chain(more.iter().cloned()).collect::>()) +} + +/// The PDF of the objects `objs` (numbered from 1, the catalog first). +fn pdf_of(objs: &[Vec]) -> Vec { let mut out = b"%PDF-1.7\n".to_vec(); let mut offsets = vec![]; for (i, o) in objs.iter().enumerate() { offsets.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); } let xref = out.len(); out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); @@ -2157,10 +2169,15 @@ fn arb_ai_token() -> impl Strategy { /// The testkit's sample editing data, compressed as older `.ai` files compress it. fn ai_sample_compressed() -> Vec { + [&b"%AI12_CompressedData"[..], &deflated(&vectorcraft_testkit::ai::sample_data())].concat() +} + +/// `data` compressed (zlib). +fn deflated(data: &[u8]) -> Vec { use std::io::Write as _; let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); - e.write_all(&vectorcraft_testkit::ai::sample_data()).unwrap(); - [&b"%AI12_CompressedData"[..], &e.finish().unwrap()].concat() + e.write_all(data).unwrap(); + e.finish().unwrap() } proptest! { @@ -2533,6 +2550,281 @@ fn deep_pdf(content: &str, resources: &str) -> Vec { handmade_pdf(content, resources, "[0 0 100 100]") } +/// A stream object with the entries `dict`, its `data` compressed. +fn packed(dict: &str, data: &str) -> Vec { + let z = deflated(data.as_bytes()); + // (A length the test gives stands.) + let length = if dict.contains("/Length") { String::new() } else { format!(" /Length {}", z.len()) }; + [format!("<< {dict} /Filter /FlateDecode{length} >>\nstream\n").as_bytes(), &z, b"\nendstream"].concat() +} + +/// A page whose content is `content`, with `resources` and the objects `more` (numbered from 5). +fn pdf_with(content: &str, resources: &str, more: &[Vec]) -> Vec { + handmade_pdf_with(content, resources, "[0 0 100 100]", more) +} + +/// Arrays in arrays, and dictionaries in dictionaries, far deeper than any reader goes: as the +/// operands of content. +fn nested_operands() -> [String; 2] { + let (n, d) = (100_000, 50_000); + [format!("BT {}{} TJ ET 0 0 5 5 re f", "[".repeat(n), "]".repeat(n)), format!("/P {}{} DP 0 0 5 5 re f", "<< /A ".repeat(d), ">> ".repeat(d))] +} + +/// A page drawing through the compressed stream that `container` makes of each of +/// [`nested_operands`] (object 5), one file each. +fn nested_in(content: &str, resources: &str, container: impl Fn(&str) -> Vec>) -> Vec> { + nested_operands().iter().map(|deep| pdf_with(content, resources, &container(deep))).collect() +} + +const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]"; + +/// A Type 3 font (object 5) whose glyph `a` is object 6. +fn type3_font(resources: &str) -> Vec { + format!( + "<< /Type /Font /Subtype /Type3 /FontBBox [0 0 10 10] /FontMatrix [0.1 0 0 0.1 0 0] /CharProcs << /a 6 0 R >> \ + /Encoding << /Type /Encoding /Differences [97 /a] >> /FirstChar 97 /LastChar 97 /Widths [10] /Resources {resources} >>" + ) + .into_bytes() +} + +/// A page whose box is object 6, kept with object 7 (a number) in a compressed object stream +/// (object 5, with the entries `stream`): a file with a cross-reference stream, as it must be. +fn pdf_with_object_stream(boxed: &str, stream: &str) -> Vec { + let content = "0 0 5 5 re f"; + let inside = format!("{boxed} 77"); + let index = format!("6 0 7 {} ", boxed.len() + 1); + let objs = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox 6 0 R /Contents 4 0 R /Resources << >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(), + packed(&format!("/Type /ObjStm /N 2 /First {} {stream}", index.len()), &format!("{index}{inside}")), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + // One entry an object: its kind, then where it is (an offset, or the stream it is in and its + // place there). + let mut entries = vec![[0u8, 0, 0, 0, 0, 255, 255]]; + let at = |kind: u8, a: usize, b: u16| { + let (a, b) = ((a as u32).to_be_bytes(), b.to_be_bytes()); + [kind, a[0], a[1], a[2], a[3], b[0], b[1]] + }; + for (i, o) in objs.iter().enumerate() { + entries.push(at(1, out.len(), 0)); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + entries.extend([at(2, 5, 0), at(2, 5, 1), at(1, out.len(), 0)]); + let xref = out.len(); + out.extend_from_slice(format!("8 0 obj\n<< /Type /XRef /Size 9 /Root 1 0 R /W [1 4 2] /Length {} >>\nstream\n", entries.len() * 7).as_bytes()); + out.extend_from_slice(&entries.concat()); + out.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +/// `n` objects from object 5 on, each made by `link` of the number of the next, then `last`. +fn chain(n: usize, link: impl Fn(usize) -> String, last: &str) -> Vec> { + (0..n).map(|i| link(i + 6).into_bytes()).chain([last.as_bytes().to_vec()]).collect() +} + +/// A page painting with the shading whose function is object 5. +fn pdf_with_function(more: &[Vec]) -> Vec { + let shading = "/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function 5 0 R >> >>"; + pdf_with("/S sh", &format!("<< {shading} >>"), more) +} + +/// A page filling in the colour space that is object 5. +fn pdf_with_color_space(more: &[Vec]) -> Vec { + pdf_with("/C cs 0 sc 0 0 5 5 re f /C CS 0 SC 0 0 5 5 re S", "<< /ColorSpace << /C 5 0 R >> >>", more) +} + +/// Every hostile PDF here opens as an error or as a document within the cap, on a small stack. +fn open_each(name: &str, files: impl IntoIterator> + Send + 'static) { + let name = name.to_string(); + on_a_small_stack(move || { + for (i, bytes) in files.into_iter().enumerate() { + open_deep(&format!("{name}-{i}.pdf"), &bytes); + // As `.ai` (the PDF part of one) and placed, too. + open_deep(&format!("{name}-{i}.ai"), &bytes); + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + let _ = no_panic(s.execute("file.place", &json!({"name": "deep.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)}))); + assert!(!s.doc().unwrap().doc.too_deep(None)); + } + }); +} + +// Nesting where only the reader of the decoded data sees it: inside each kind of compressed +// stream a page draws through. + +#[test] +fn pdf_nesting_inside_a_compressed_form() { + open_each("form", nested_in("/F Do", "<< /XObject << /F 5 0 R >> >>", |deep| vec![packed(FORM, deep)])); +} + +#[test] +fn pdf_nesting_inside_a_compressed_pattern() { + let tile = "/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources << >>"; + open_each("pattern", nested_in("/Pattern cs /P scn 0 0 50 50 re f", "<< /Pattern << /P 5 0 R >> >>", |deep| vec![packed(tile, deep)])); +} + +#[test] +fn pdf_nesting_inside_a_compressed_glyph() { + let glyph = |deep: &str| vec![type3_font("<< >>"), packed("", &format!("10 0 d0 {deep}"))]; + open_each("glyph", nested_in("BT /F1 12 Tf 10 10 Td (aa) Tj ET", "<< /Font << /F1 5 0 R >> >>", glyph)); +} + +#[test] +fn pdf_nesting_inside_a_compressed_soft_mask() { + let group = format!("{FORM} /Group << /S /Transparency /CS /DeviceGray >>"); + let state = "<< /ExtGState << /G << /Type /ExtGState /SMask << /S /Luminosity /G 5 0 R >> >> >> >>"; + open_each("mask", nested_in("/G gs 0 0 50 50 re f", state, move |deep| vec![packed(&group, deep)])); +} + +#[test] +fn pdf_nesting_inside_a_compressed_annotation() { + let annots = "<< >> /Annots [<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /AP << /N 5 0 R >> >>]"; + open_each("annotation", nested_in("0 0 5 5 re f", annots, |deep| vec![packed(FORM, deep)])); +} + +#[test] +fn pdf_nesting_inside_compressed_page_content() { + let files = nested_operands().map(|deep| { + let mut objs: Vec> = ["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>"].map(Vec::from).into(); + objs.push(b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents [4 0 R 4 0 R] /Resources << >> >>".to_vec()); + objs.push(packed("", &deep)); + pdf_of(&objs) + }); + open_each("content", files); +} + +#[test] +fn pdf_nesting_inside_an_object_stream() { + let (n, d) = (100_000, 50_000); + open_each( + "objects", + [ + // A plain one first: the file is read as it should be. + pdf_with_object_stream("[0 0 100 100]", ""), + pdf_with_object_stream(&format!("{}0 0 100 100{}", "[".repeat(n), "]".repeat(n)), ""), + pdf_with_object_stream(&format!("{}{}", "<< /A ".repeat(d), ">> ".repeat(d)), ""), + // Its length is an object kept in the stream itself. + pdf_with_object_stream("[0 0 100 100]", "/Length 7 0 R"), + // It extends itself. + pdf_with_object_stream("[0 0 100 100]", "/Extends 5 0 R"), + ], + ); + let plain = vectorcraft_engine::cmd::fileio::load("plain.pdf", &pdf_with_object_stream("[0 0 120 80]", "")).expect("an object stream is read"); + assert_eq!(plain.doc.artboards[0].rect.width(), 120.0); +} + +// What refers to itself, or to the next of its kind without end. + +#[test] +fn pdf_page_trees_that_hold_themselves() { + let page = |parent: usize| format!("<< /Type /Page /Parent {parent} 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << >> >>"); + let content = "<< /Length 13 >>\nstream\n0 0 5 5 re f\nendstream"; + let own = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [2 0 R 3 0 R 2 0 R] /Count 1 >>".to_string(), + page(2), + content.to_string(), + ]; + // Thousands of nodes, each holding the next; the page is the last. + let n = 30_000; + let mut deep = vec!["<< /Type /Catalog /Pages 5 0 R >>".to_string(), "null".to_string(), "null".to_string(), content.to_string()]; + deep.extend((0..n).map(|i| format!("<< /Type /Pages /Kids [{} 0 R] /Count 1 >>", i + 6))); + deep.push(page(n + 4)); + // Two nodes holding each other. + let pair = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [5 0 R] /Count 1 >>".to_string(), + page(5), + content.to_string(), + "<< /Type /Pages /Kids [2 0 R 3 0 R] /Count 1 >>".to_string(), + ]; + let bytes = |objs: &[String]| pdf_of(&objs.iter().map(|o| o.clone().into_bytes()).collect::>()); + open_each("pages", [bytes(&own), bytes(&deep), bytes(&pair)]); +} + +#[test] +fn pdf_functions_that_hold_themselves() { + let stitch = |next: usize| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{next} 0 R] /Bounds [] /Encode [0 1] >>"); + let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>"; + let calculator = "/FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1]"; + let n = 100_000; + open_each( + "functions", + [ + pdf_with_function(&[stitch(5).into_bytes()]), + pdf_with_function(&[stitch(6).into_bytes(), stitch(5).into_bytes()]), + pdf_with_function(&chain(20_000, stitch, ramp)), + // A calculator function of procedures in procedures. + pdf_with_function(&[packed(calculator, &format!("{}{}", "{".repeat(n), "}".repeat(n)))]), + pdf_with_function(&[packed(calculator, &format!("{{ {} 0 0 0 {} }}", "true {".repeat(n), "} if ".repeat(n)))]), + ], + ); +} + +#[test] +fn pdf_color_spaces_that_hold_themselves() { + let indexed = |next: usize| format!("[/Indexed {next} 0 R 1 <000000ffffff>]"); + let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 0 0] /N 1 >>"; + let profile = |alternate: &str| format!("<< /N 3 /Alternate {alternate} /Length 4 >>\nstream\njunk\nendstream").into_bytes(); + open_each( + "spaces", + [ + pdf_with_color_space(&[indexed(5).into_bytes()]), + pdf_with_color_space(&[indexed(6).into_bytes(), indexed(5).into_bytes()]), + pdf_with_color_space(&chain(20_000, indexed, "/DeviceRGB")), + pdf_with_color_space(&[b"[/Separation /Ink 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]), + pdf_with_color_space(&[b"[/DeviceN [/Ink] 5 0 R 6 0 R]".to_vec(), tint.as_bytes().to_vec()]), + pdf_with_color_space(&[b"[/Pattern 5 0 R]".to_vec()]), + pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("5 0 R")]), + pdf_with_color_space(&[b"[/ICCBased 6 0 R]".to_vec(), profile("[/ICCBased 6 0 R]")]), + // As the space of a shading and of an image. + pdf_with( + "/S sh", + "<< /Shading << /S << /ShadingType 2 /ColorSpace 5 0 R /Coords [0 0 9 0] /Function 6 0 R >> >> >>", + &[indexed(5).into_bytes(), tint.as_bytes().to_vec()], + ), + pdf_with( + "q 50 0 0 50 0 0 cm /I Do Q", + "<< /XObject << /I 6 0 R >> >>", + &[ + indexed(5).into_bytes(), + b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace 5 0 R /Length 1 >>\nstream\n0\nendstream" + .to_vec(), + ], + ), + ], + ); +} + +#[test] +fn pdf_art_that_draws_itself() { + let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>"; + let tile = format!("/PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 10 10] /XStep 10 /YStep 10 /Resources {own}"); + let group = format!("{FORM} /Group << /S /Transparency >> /Resources {own}"); + open_each( + "itself", + [ + // A form drawing itself twice, a pattern painting its own tile, a glyph showing + // itself, and a mask masked by itself. + pdf_with("/F Do", own, &[packed(&format!("{FORM} /Resources {own}"), "0 0 5 5 re f /F Do 1 0 0 1 3 3 cm /F Do")]), + pdf_with("/Pattern cs /P scn 0 0 50 50 re f", own, &[packed(&tile, "/Pattern cs /P scn 0 0 5 5 re f")]), + pdf_with("BT /F1 12 Tf (a) Tj ET", own, &[type3_font(own), packed("", "10 0 d0 0 0 5 5 re f BT /F1 1 Tf (a) Tj ET")]), + pdf_with("/G gs 0 0 50 50 re f", own, &[packed(&group, "/G gs 0 0 5 5 re f")]), + // An image that is its own mask, and objects that are only a reference to each other. + pdf_with("q 50 0 0 50 0 0 cm /F Do Q", own, &[ + b"<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /BitsPerComponent 8 /ColorSpace /DeviceGray /SMask 5 0 R /Length 1 >>\nstream\n0\nendstream".to_vec(), + ]), + handmade_pdf_with("0 0 5 5 re f", "5 0 R", "6 0 R", &[b"6 0 R".to_vec(), b"5 0 R".to_vec()]), + handmade_pdf_with("0 0 5 5 re f", "<< >>", "[0 0 9 9]", &[b"<< /Length 5 0 R >>\nstream\nx\nendstream".to_vec()]), + ], + ); +} + /// An EPS whose program is `body`. fn deep_eps(body: &str) -> Vec { format!("%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n{body}\nshowpage\n%%EOF\n").into_bytes() diff --git a/crates/format/src/stream.rs b/crates/format/src/stream.rs index 60879c07a..bed508601 100644 --- a/crates/format/src/stream.rs +++ b/crates/format/src/stream.rs @@ -66,7 +66,8 @@ impl Write for Out { const OUT_CHUNK: usize = 64 << 10; /// A gzip stream, written as [`flate2::write::GzEncoder`] at the default level writes it (the -/// same header, and the same bytes for a file without compressed images), except that data which +/// same header and the same data; the deflate blocks are the same too where flate2 deflates with +/// miniz_oxide, not with zlib-rs, which writes them by how the data is fed), except that data which /// is compressed already ([`Gz::packed`]) is stored as it is: the default level spends a second /// or more per photo on it for next to nothing, and makes it slower to unpack. Where the level /// changes the deflate blocks so far end with a sync flush and a fresh compressor writes on, diff --git a/crates/format/tests/streaming.rs b/crates/format/tests/streaming.rs index 81d935f85..6149f1995 100644 --- a/crates/format/tests/streaming.rs +++ b/crates/format/tests/streaming.rs @@ -1,7 +1,7 @@ //! Saving and opening as streams: the bytes a save writes are those an in-memory save wrote -//! before (the same JSON and data after it, unpacked; the same gzip bytes when no image is -//! compressed already), files written the old way still open, entries are read whatever their -//! order, and the limits apply per part of the file. +//! before (the same JSON and data after it, unpacked; the same gzip header), files written the old +//! way still open, entries are read whatever their order, and the limits apply per part of the +//! file. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] @@ -67,13 +67,17 @@ fn compressed_files_unpack_to_the_plain_file() { } } +/// The deflate bytes themselves depend on flate2's backend: zlib-rs (which hayro turns on) writes +/// other blocks for data fed in other pieces than the in-memory save fed it, miniz_oxide didn't. #[test] -fn without_compressed_images_the_gzip_bytes_are_the_old_ones() { +fn without_compressed_images_the_gzip_stream_is_the_old_one() { let mut d = doc_with(&[("scan", "image/tiff", INLINE_MAX * 2), ("icon", "image/png", 100)]); d.title = "No photos".into(); for o in [SaveOptions::default(), SaveOptions { pdf: Some(vec![b'%'; INLINE_MAX]), ..SaveOptions::default() }] { let plain = save_with(&d, &o).unwrap(); - assert_eq!(save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain)); + let (packed, old) = (save_with(&d, &SaveOptions { compress: true, ..o }).unwrap(), gzip(&plain)); + assert_eq!(packed.get(..10), old.get(..10), "the same header"); + assert_eq!(gunzip(&packed), plain); } } diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 002863aed..e53fc21b8 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -23,8 +23,8 @@ image = { workspace = true } # CMYK JPEGs (the image crate writes RGB and grey ones only). jpeg-encoder = { workspace = true } krilla = { workspace = true } -hayro-syntax = "0.7.2" -hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] } +hayro-syntax = "0.8.0" +hayro-interpret = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] } log = { workspace = true } smallvec = "1" serde = { workspace = true } diff --git a/crates/pdf/src/import.rs b/crates/pdf/src/import.rs index d3116ce62..cc2bad674 100644 --- a/crates/pdf/src/import.rs +++ b/crates/pdf/src/import.rs @@ -3,12 +3,12 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; -use hayro_interpret::font::Glyph; +use hayro_interpret::font::{Glyph, GlyphRun as ShownGlyphs}; use hayro_interpret::hayro_cmap::BfString; use hayro_interpret::pattern::{Pattern, TilingPattern}; use hayro_interpret::{ - CacheKey, ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, - MaskType, PathDrawMode, SoftMask, StrokeProps, interpret_page, + CacheKey, ClipPath, Context, Device, DrawMode, DrawProps, Image, ImageData, ImageDrawProps, InterpreterCache, InterpreterSettings, + InterpreterWarning, LumaData, MaskType, SoftMask, StrokeProps, interpret_page, }; use hayro_syntax::object::Name; use kurbo::{Affine, BezPath, PathEl, Rect, Shape}; @@ -147,9 +147,15 @@ pub const PAST_PAGE_NOTE: &str = "art reaching past the page was clipped to it, /// Import a PDF, returning the document plus warnings about content that was approximated or skipped. pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result { + // The interpreter draws forms, patterns, glyphs and masks inside one another by recursion, in + // large stack frames: it caps that (50 deep), and gets room for the cap whatever thread asks. + vectorcraft_doc::depth::with_deep_stack(|| read(bytes, opts)) +} + +/// [`import_with_report`] on the thread that asks. +pub(crate) fn read(bytes: &[u8], opts: &ImportOptions) -> Result { let original = crate::pages::open(bytes, opts.password.as_deref())?; let picked = crate::pages::picked(opts, original.pages().len())?; - picked.iter().filter_map(|&n| original.pages().get(n)).try_for_each(crate::pages::content_too_deep)?; let mut ocgs = Ocgs::read(&original); let route = opts.layers && !ocgs.list.is_empty(); // Content that is off imports as hidden layers: an update of the file turns every group on @@ -167,7 +173,6 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result>> = Arc::default(); let sink2 = sink.clone(); @@ -176,6 +181,9 @@ pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result "a font could not be read; its text was skipped", InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped", + // Annotations aren't drawn (`render_annotations` below), and a kind of warning + // added later has no message yet. + _ => return, }; if let Ok(mut v) = sink2.lock() && !v.iter().any(|x| x == msg) @@ -1262,8 +1270,8 @@ impl<'p> Builder<'p> { /// before it on the open line was stroked so too, is held with the line instead of ending it /// (an editor writes type on a path glyph by glyph, each followed by the stroke added to the /// type). `false`: it isn't one. - fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &PathDrawMode) -> bool { - let PathDrawMode::Stroke(props) = draw_mode else { return false }; + fn hold_glyph_stroke(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'_>, draw_mode: &DrawMode) -> bool { + let DrawMode::Stroke(props) = draw_mode else { return false }; if self.mask.is_some() || self.blend != BlendMode::Normal || matches!(paint, hayro_interpret::Paint::Pattern(_)) { return false; } @@ -1355,7 +1363,11 @@ impl<'p> Builder<'p> { Some(art) => { let [r, g, b, _] = m.background_color().to_rgba().components(); let backdrop = luminance(&Color::rgb(r, g, b)); - let inverted = m.transfer_function().is_some_and(|tf| tf.apply(0.0) > tf.apply(1.0)); + let inverted = m.transfer_function().is_some_and(|tf| { + let mut ends = [0, 255]; + tf.apply_to(&mut ends); + ends[0] > ends[1] + }); let id = self.id(); mask_spec(art, m.mask_type() == MaskType::Alpha, backdrop, inverted, page, |all| Node::group(id, all)) } @@ -1684,7 +1696,14 @@ fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec { } } -impl<'a> Device<'a> for Builder<'_> { +/// What the interpreter draws, as the device below gets it. +impl<'a> Builder<'_> { + /// The soft mask and blend mode of what is drawn next. + fn set_state(&mut self, mask: Option>, blend_mode: hayro_interpret::BlendMode) { + self.set_soft_mask(mask); + self.set_blend_mode(blend_mode); + } + fn set_soft_mask(&mut self, mask: Option>) { let spec = mask.map(|m| self.soft_mask(&m)); let same = match (&spec, &self.mask) { @@ -1707,7 +1726,7 @@ impl<'a> Device<'a> for Builder<'_> { } } - fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) { + fn path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &DrawMode) { self.flush_glyphs(); if self.hold_glyph_stroke(path, transform, paint, draw_mode) { self.pending = false; @@ -1729,19 +1748,19 @@ impl<'a> Device<'a> for Builder<'_> { hayro_interpret::Paint::Color(_) => None, }; // Patch and triangle meshes are gradient meshes. - if let (PathDrawMode::Fill(_), Some(sp)) = (draw_mode, shading) + if let (DrawMode::Fill(_), Some(sp)) = (draw_mode, shading) && let Some(meshes) = mesh_shading(sp, &mut self.colors) { self.draw_meshes(meshes, &bp, sp.opacity); return; } let pd = PathData::from_bezpath(&bp); - let stroke = matches!(draw_mode, PathDrawMode::Stroke(_)); + let stroke = matches!(draw_mode, DrawMode::Stroke(_)); let (paint, opacity) = self.paint(paint, stroke); // A gradient that doesn't extend past its ends paints only between them. let width = match draw_mode { - PathDrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform), - PathDrawMode::Fill(_) => 0.0, + DrawMode::Stroke(p) => p.line_width as f64 * mean_scale(transform), + _ => 0.0, }; let band = shading.and_then(|sp| extend_clip(sp, bp.bounding_box().inflate(width, width))); let wrap = |b: &mut Self, n: Node| match &band { @@ -1753,7 +1772,7 @@ impl<'a> Device<'a> for Builder<'_> { None => n, }; match draw_mode { - PathDrawMode::Fill(rule) => { + DrawMode::Fill(rule) => { let mut f = FillLayer::new(paint); (f.opacity, f.overprint) = (opacity, overprint); let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], ..Default::default() }); @@ -1764,7 +1783,7 @@ impl<'a> Device<'a> for Builder<'_> { let n = wrap(self, n); self.push_node(n); } - PathDrawMode::Stroke(props) => { + DrawMode::Stroke(props) => { let mut st = stroke_layer(paint, opacity, props, mean_scale(transform)); st.overprint = overprint; // Fill-then-stroke of the same path (the `B` operator, or a fill and then a stroke @@ -1799,6 +1818,71 @@ impl<'a> Device<'a> for Builder<'_> { let n = wrap(self, n); self.push_node(n); } + // (`draw_path` gives a fill and a stroke one after the other, and no path is invisible.) + DrawMode::FillAndStroke(..) | DrawMode::Invisible => {} + } + } + + /// One glyph: filled, or stroked with `stroke`. + fn glyph( + &mut self, + glyph: &Glyph<'a>, + transform: Affine, + glyph_transform: Affine, + paint: &hayro_interpret::Paint<'a>, + stroke: Option<&StrokeProps>, + ) { + self.pending = false; + match glyph { + Glyph::Outline(o) => { + let scale = mean_scale(transform); + let overprint = self.glyph_overprint(stroke.is_some()); + if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke, overprint) { + return; + } + self.flush_text(); + let mut bp = o.outline(); + bp.apply_affine(transform * glyph_transform); + let (paint, opacity) = self.paint(paint, stroke.is_some()); + let same = self.glyphs.as_ref().is_some_and(|r| { + r.paint == paint + && r.opacity == opacity + && r.overprint == overprint + && r.stroke.as_ref().map(|s| s.line_width) == stroke.map(|s| s.line_width) + && (r.scale - scale).abs() < 1e-9 + }); + if !same { + self.flush_glyphs(); + self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke: stroke.cloned(), scale, overprint }); + } + if let Some(r) = &mut self.glyphs { + r.path.extend(bp.iter()); + } + self.warn("text was converted to outlines"); + } + Glyph::Type3(t3) => { + if self.nested >= MAX_NESTED { + return; + } + self.flush(); + self.nested += 1; + t3.interpret(self, transform, glyph_transform, paint); + self.nested -= 1; + } + } + } +} + +impl<'a> Device<'a> for Builder<'_> { + fn draw_path(&mut self, path: &BezPath, props: DrawProps<'a>, draw_mode: &DrawMode) { + self.set_state(props.soft_mask, props.blend_mode); + match draw_mode { + DrawMode::Invisible => {} + DrawMode::FillAndStroke(rule, stroke) => { + self.path(path, props.transform, &props.paint, &DrawMode::Fill(*rule)); + self.path(path, props.transform, &props.paint, &DrawMode::Stroke(stroke.clone())); + } + one => self.path(path, props.transform, &props.paint, one), } } @@ -1834,60 +1918,22 @@ impl<'a> Device<'a> for Builder<'_> { self.blend = BlendMode::Normal; } - fn draw_glyph( - &mut self, - glyph: &Glyph<'a>, - transform: Affine, - glyph_transform: Affine, - paint: &hayro_interpret::Paint<'a>, - draw_mode: &GlyphDrawMode, - ) { - self.pending = false; + fn draw_glyph_run(&mut self, glyph_run: &ShownGlyphs<'_, 'a>, props: DrawProps<'a>, draw_mode: &DrawMode) { + // (A run is filled or stroked: one filled and stroked is given twice.) let stroke = match draw_mode { - GlyphDrawMode::Invisible => return, - GlyphDrawMode::Fill => None, - GlyphDrawMode::Stroke(p) => Some(p.clone()), + DrawMode::Invisible => return, + DrawMode::Fill(_) => None, + DrawMode::Stroke(p) | DrawMode::FillAndStroke(_, p) => Some(p), }; - match glyph { - Glyph::Outline(o) => { - let scale = mean_scale(transform); - let overprint = self.glyph_overprint(stroke.is_some()); - if self.type_glyph(o, transform * glyph_transform, scale, paint, stroke.as_ref(), overprint) { - return; - } - self.flush_text(); - let mut bp = o.outline(); - bp.apply_affine(transform * glyph_transform); - let (paint, opacity) = self.paint(paint, stroke.is_some()); - let same = self.glyphs.as_ref().is_some_and(|r| { - r.paint == paint - && r.opacity == opacity - && r.overprint == overprint - && r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width) - && (r.scale - scale).abs() < 1e-9 - }); - if !same { - self.flush_glyphs(); - self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale, overprint }); - } - if let Some(r) = &mut self.glyphs { - r.path.extend(bp.iter()); - } - self.warn("text was converted to outlines"); - } - Glyph::Type3(t3) => { - if self.nested >= MAX_NESTED { - return; - } - self.flush(); - self.nested += 1; - t3.interpret(self, transform, glyph_transform, paint); - self.nested -= 1; - } + for g in glyph_run.glyphs() { + self.set_state(props.soft_mask.clone(), props.blend_mode); + self.glyph(g, props.transform, g.transform(), &props.paint, stroke); } } - fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) { + fn draw_image(&mut self, image: Image<'a, '_>, props: ImageDrawProps<'a>) { + self.set_state(props.soft_mask, props.blend_mode); + let transform = props.transform; self.flush_glyphs(); self.flush_text(); self.pending = false; @@ -1975,7 +2021,7 @@ impl<'a> Device<'a> for Builder<'_> { } } - fn pop_clip_path(&mut self) { + fn pop_clip(&mut self) { self.flush(); self.pending = false; self.pop_frame(); diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index 7390ecd09..4b7f4106d 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -379,7 +379,7 @@ impl<'p> Colors<'p> { } /// An object written in place (references are visited as objects of their own). -fn direct(m: MaybeRef>) -> Option> { +pub(crate) fn direct(m: MaybeRef>) -> Option> { match m { MaybeRef::NotRef(o) => Some(o), MaybeRef::Ref(_) => None, diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 2d09842e6..b3a6d2c9f 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -485,7 +485,8 @@ impl Walker<'_, '_> { { self.out.groups.push((g.get::(b"I").unwrap_or(false), g.get::(b"K").unwrap_or(false))); } - let inner = Resources::from_parent(d.get::>(b"Resources").unwrap_or_default(), res.clone()); + // (Its own resources, or without any those of what draws it: as the interpreter reads them.) + let inner = d.get::>(b"Resources").map(Resources::new).unwrap_or_else(|| res.clone()); self.save(); self.walk(TypedIter::new(data.as_ref()), &inner, depth + 1); self.restore(); diff --git a/crates/pdf/src/import_shading.rs b/crates/pdf/src/import_shading.rs index a5783cf09..c81438d49 100644 --- a/crates/pdf/src/import_shading.rs +++ b/crates/pdf/src/import_shading.rs @@ -34,7 +34,7 @@ pub(crate) fn shading_gradient(sp: &ShadingPattern, colors: &mut Colors<'_>) -> let space = colors.shading_space(cs, at(0.0)?.len()); // RGB to decide which samples are stops; the stops keep the shading's model. let sample = |t: f32| -> Option { - let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0, false).components(); + let [r, g, b, _] = cs.to_rgba(&at(t)?, 1.0).components(); Some(Color::rgb(round3(r), round3(g), round3(b))) }; // Sample densely, then drop samples that linear interpolation reproduces. @@ -147,7 +147,7 @@ fn mesh_color(v: &[f32], function: Option<&ShadingFunction>, sp: &ShadingPattern }; let space = colors.shading_space(&sp.shading.color_space, comps.len()); colors.native(space, &comps).map(|n| n.color).unwrap_or_else(|| { - let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0, false).components(); + let [r, g, b, _] = sp.shading.color_space.to_rgba(&comps, 1.0).components(); Color::rgb(round3(r), round3(g), round3(b)) }) } diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 29817514b..9d38e43a1 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -1,12 +1,16 @@ //! The pages of a PDF to import: opening with a password, the pages picked, the box each page //! is cropped to ([`CropTo`]) and [`info`]. -use hayro_syntax::object::{Dict, Stream}; +use std::collections::HashMap; + +use hayro_syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream}; use hayro_syntax::page::Page; +use hayro_syntax::xref::XRef; use hayro_syntax::{DecryptionError, LoadPdfError, Pdf}; use kurbo::{Affine, Rect}; use vectorcraft_doc::setup::MAX_BLEED; +use crate::import_color::direct; use crate::{Choice, CropTo, ImportOptions, PdfError}; /// What `info` tells about one page. @@ -33,76 +37,116 @@ pub fn is_postscript(bytes: &[u8]) -> bool { bytes.starts_with(b"%!PS") || bytes.starts_with(&[0xC5, 0xD0, 0xD3, 0xC6]) } -/// The deepest a file's arrays and dictionaries may nest. The PDF reader (hayro) reads them by -/// recursion, without a cap of its own: a file nested deeply enough overflows the stack, which is -/// an abort, not an error. Far beyond what a PDF writer makes. -const MAX_OBJECT_NESTING: usize = 256; +/// The most functions and colour spaces that may be built on one another (a function stitching +/// functions together, an indexed space on a space with an alternate). The interpreter (hayro) +/// builds them by recursion, without a cap of its own: one built on itself, or thousands in a +/// chain, overflows the stack, which is an abort, not an error. Files have two or three. +const MAX_LINKS: usize = 16; -fn too_deep() -> PdfError { - PdfError::Parse(format!("its arrays and dictionaries are nested more than {MAX_OBJECT_NESTING} deep")) +/// The deepest the procedures of a calculator function may nest: read by recursion, too. +const MAX_PROCEDURES: usize = 64; + +/// What the function or colour space `o` is built on: the functions a stitching function joins; +/// the base, alternate and tint of a colour space; the alternate of a colour profile. Nothing for +/// any other object. +fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec>> { + const SPACES: [&[u8]; 6] = [b"Indexed", b"I", b"Separation", b"DeviceN", b"Pattern", b"ICCBased"]; + if let Object::Array(a) = o { + let mut items = a.raw_iter(); + let kind = match items.next() { + Some(MaybeRef::NotRef(Object::Name(n))) => Some(n), + Some(MaybeRef::Ref(r)) => xref.get::>(r.into()), + _ => None, + }; + return if kind.is_some_and(|n| SPACES.contains(&n.as_ref())) { items.collect() } else { vec![] }; + } + let Some((d, _)) = dict_or_stream(o) else { return vec![] }; + let functions = d.get::>(b"Functions").map(|a| a.raw_iter().collect::>()).unwrap_or_default(); + functions.into_iter().chain(d.get_raw::>(b"Alternate")).collect() } -/// Do the arrays and dictionaries in the text of `pdf` nest deeper than [`MAX_OBJECT_NESTING`]? -/// Read as tokens (strings and comments hold none), an object at a time. The data of a stream -/// with a filter is skipped: it isn't PDF text until decoded, which this doesn't do, so nesting -/// inside compressed streams is not seen here. -fn nests_too_deep(pdf: &[u8]) -> bool { - let after = - |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at + what.len()); - let word = |at: usize, w: &[u8]| pdf.get(at..).is_some_and(|rest| rest.starts_with(w)); - let (mut depth, mut filtered) = (0usize, false); - let mut i = 0; - while let Some(&c) = pdf.get(i) { - i += 1; +/// How many of its kind the function or colour space `o` is built on, one on the next (0 for any +/// other object); `None` past `room`, or when it is built on itself. `known`: the objects looked +/// at so far (`None` while one is being looked at, too). One `None` refuses the file, so that an +/// object is `None` only for the room there was where it was first met doesn't matter. +fn built_on<'a>(o: MaybeRef>, xref: &'a XRef, known: &mut HashMap>, room: usize) -> Option { + let (id, o) = match o { + MaybeRef::Ref(r) => { + let id = ObjectIdentifier::from(r); + if let Some(found) = known.get(&id) { + return *found; + } + let Some(o) = xref.get::>(id) else { return Some(0) }; + known.insert(id, None); + (Some(id), o) + } + MaybeRef::NotRef(o) => (None, o), + }; + let links = links(&o, xref); + let most = match room.checked_sub(1) { + _ if links.is_empty() => Some(0), + Some(room) => links.into_iter().try_fold(0, |most, on| built_on(on, xref, known, room).map(|n| most.max(n + 1))), + None => None, + }; + // (What it is built on may be known already, and deeper than there is room for here.) + let most = most.filter(|n| *n <= room); + if let Some(id) = id { + known.insert(id, most); + } + most +} + +/// Is `o` a calculator function whose procedures nest deeper than [`MAX_PROCEDURES`]? +fn procedures_too_deep(o: &Object<'_>) -> bool { + let Object::Stream(s) = o else { return false }; + if s.dict().get::(b"FunctionType") != Some(4) { + return false; + } + let Ok(program) = s.decoded() else { return false }; + let mut depth = 0usize; + program.iter().any(|c| { match c { - b'%' => i += pdf.get(i..).and_then(|rest| rest.iter().position(|c| matches!(c, b'\n' | b'\r'))).unwrap_or(pdf.len()), - b'(' => { - // A string: parentheses nest in it, a backslash takes the next byte. - let mut open = 1usize; - while open > 0 { - match pdf.get(i) { - None => return false, - Some(b'\\') => i += 1, - Some(b'(') => open += 1, - Some(b')') => open -= 1, - Some(_) => {} - } - i += 1; - } - } - b'[' => depth += 1, - b']' => depth = depth.saturating_sub(1), - b'<' if pdf.get(i) == Some(&b'<') => (depth, i) = (depth + 1, i + 1), - b'>' if pdf.get(i) == Some(&b'>') => (depth, i) = (depth.saturating_sub(1), i + 1), - // A string in hexadecimal. - b'<' => i = after(i, b">").unwrap_or(pdf.len()), - b'/' if word(i - 1, b"/Filter") => filtered = true, - b'e' if word(i - 1, b"endobj") => (depth, filtered) = (0, false), - b'e' if word(i - 1, b"endstream") => i += 8, - b's' if word(i - 1, b"stream") => { - depth = 0; - if std::mem::take(&mut filtered) { - i = after(i, b"endstream").unwrap_or(pdf.len()); - } - } + b'{' => depth += 1, + b'}' => depth = depth.saturating_sub(1), _ => {} } - if depth > MAX_OBJECT_NESTING { + depth > MAX_PROCEDURES + }) +} + +/// Does `pdf` hold functions or colour spaces built on one another past [`MAX_LINKS`] (or on +/// themselves), or a calculator function nested past [`MAX_PROCEDURES`]? Every object is looked +/// at, wherever it is used from: the interpreter builds them from resources, shadings, images, +/// patterns, masks and graphics states alike. +fn nests_without_end(pdf: &Pdf) -> bool { + let xref = pdf.xref(); + let mut known = HashMap::new(); + // The objects of the file, then what each holds directly (64 deep at most: the reader's cap). + let mut pending: Vec> = pdf.objects().into_iter().collect(); + while let Some(o) = pending.pop() { + if procedures_too_deep(&o) || built_on(MaybeRef::NotRef(o.clone()), xref, &mut known, MAX_LINKS).is_none() { return true; } + match &o { + Object::Array(a) => pending.extend(a.raw_iter().filter_map(direct)), + _ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| d.entries()).filter_map(|(_, v)| direct(v))), + } } false } /// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions /// password opens a file too. +/// +/// What a hostile file nests or loops is the reader's to refuse (hayro 0.8 reads arrays and +/// dictionaries 64 deep and no deeper, wherever they are: the file's text, an object stream, the +/// content of a page, form, pattern or glyph; and follows a page tree or a chain of object streams +/// once). What it reads beyond that is left out, as an object it can't read. What the interpreter +/// builds by recursion with no cap is refused here ([`nests_without_end`]). pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { if is_postscript(bytes) { return Err(PdfError::PostScript); } - if nests_too_deep(bytes) { - return Err(too_deep()); - } let password = password.unwrap_or_default(); let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw); read(password) @@ -119,12 +163,12 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result PdfError::WrongPassword, e => PdfError::Parse(format!("{e:?}")), }) -} - -/// The error of a page whose content, decoded, nests arrays and dictionaries too deep (see -/// [`MAX_OBJECT_NESTING`]): its operands are read the same way as the file's objects. -pub(crate) fn content_too_deep(page: &Page<'_>) -> Result<(), PdfError> { - if page.page_stream().is_some_and(nests_too_deep) { Err(too_deep()) } else { Ok(()) } + // Twice: looking at every object makes the reader repair a damaged table of objects (once + // only), and the objects are other ones after that. + .and_then(|pdf| match (0..2).any(|_| nests_without_end(&pdf)) { + true => Err(PdfError::Parse("its functions or colour spaces are built on one another without end".into())), + false => Ok(pdf), + }) } /// The pages and their boxes. An encrypted PDF needs its `password` diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 29e7469ba..f09f33f55 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -734,3 +734,46 @@ fn opacity_mask_exports_as_luminosity_soft_mask() { assert!(text.contains("/SMask"), "soft mask in an ExtGState"); assert!(text.contains("/Luminosity")); } + +/// A function or colour space built on itself is refused before the interpreter builds it (by +/// recursion, with no cap: a stack overflow), and those of real files are read. +#[test] +fn functions_and_colour_spaces_built_on_themselves_are_refused() { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; + let first = first_extra(1); + let (me, next) = (format!("{first} 0 R"), format!("{} 0 R", first + 1)); + let shaded = PdfPage { + resources: format!("/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function {me} >> >>"), + ..PdfPage::new(100.0, 100.0, "/S sh") + }; + let filled = PdfPage { resources: format!("/ColorSpace << /C {me} >>"), ..PdfPage::new(100.0, 100.0, "/C cs 0 sc 0 0 50 50 re f") }; + let stitch = |on: &str| format!("<< /FunctionType 3 /Domain [0 1] /Functions [{on}] /Bounds [] /Encode [0 1] >>"); + let indexed = |on: &str| format!("[/Indexed {on} 1 <000000ffffff>]"); + let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>"; + // `n` functions, each stitching the next, then a plain one. + let chain = |n: usize| (1..=n).map(|i| stitch(&format!("{} 0 R", first + i))).chain([ramp.to_string()]).collect::>(); + let calculator = + |program: &str| format!("<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length {} >>\nstream\n{program}\nendstream", program.len()); + for (what, page, objects) in [ + ("a function stitching itself", &shaded, vec![stitch(&me)]), + ("two functions stitching each other", &shaded, vec![stitch(&next), stitch(&me)]), + ("twenty functions, each stitching the next", &shaded, chain(20)), + ("procedures in procedures", &shaded, vec![calculator(&format!("{}{}", "{".repeat(100), "}".repeat(100)))]), + ("a colour space on itself", &filled, vec![indexed(&me)]), + ("two colour spaces on each other", &filled, vec![indexed(&next), indexed(&me)]), + ] { + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let e = import(&pdf_with(std::slice::from_ref(page), &objects, None)).err().map(|e| e.to_string()); + assert!(e.as_deref().is_some_and(|e| e.contains("built on one another without end")), "{what}: {e:?}"); + } + for (what, page, objects) in [ + ("a function stitching one", &shaded, vec![stitch(&next), ramp.to_string()]), + ("three functions, each stitching the next", &shaded, chain(3)), + ("a calculator function", &shaded, vec![calculator("{ dup 1 exch sub 0 }")]), + ("an indexed colour space", &filled, vec![indexed("/DeviceRGB")]), + ] { + let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); + let d = import(&pdf_with(std::slice::from_ref(page), &objects, None)).unwrap_or_else(|e| panic!("{what}: {e}")); + assert!(d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "{what}: its art is read"); + } +} diff --git a/crates/pdf/src/tests_import_layers.rs b/crates/pdf/src/tests_import_layers.rs index c2c46d212..82032f2c6 100644 --- a/crates/pdf/src/tests_import_layers.rs +++ b/crates/pdf/src/tests_import_layers.rs @@ -287,7 +287,7 @@ fn a_sublayer_that_paints_first_brings_its_parent_layer() { #[test] fn hostile_layer_orders_stay_bounded() { // A group listed twice, under itself, and an order nested far deeper than layers go. - let deep = format!("{}{{2}}{}", "[".repeat(100), "]".repeat(100)); + let deep = format!("{}{{2}}{}", "[".repeat(40), "]".repeat(40)); let order = format!("[{{0}} [{{0}} {{1}}] {{1}} [{{0}}] {deep}]"); let d = import(&three_groups([&ocg("A", ""), &ocg("B", ""), &ocg("C", "")], &[], &format!("/Order {order}"))).unwrap(); assert_eq!(names(&d), ["A", "C"], "C, listed too deep, stays a top-level layer"); @@ -317,7 +317,8 @@ fn thousands_of_layers_hidden_ones_included_all_come_in() { /// Import `bytes` with the scan's operator limit lowered to `n`. fn import_with_max_ops(bytes: &[u8], n: usize) -> ImportReport { let old = crate::import_scan::TEST_MAX_OPS.replace(n); - let r = import_with_report(bytes, &ImportOptions::default()); + // (On this thread: the limit is this thread's.) + let r = crate::import::read(bytes, &ImportOptions::default()); crate::import_scan::TEST_MAX_OPS.set(old); r.unwrap() } From 0efcaeb5587fd96c1c0e8ac2210159d3646ef477 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 09:41:03 +0700 Subject: [PATCH 24/37] Importers: a brush that draws itself by name no longer crashes A scatter brush whose art is an instance of a symbol stroked with that brush, or any brush whose art is painted with a pattern whose tile is stroked with it, drew itself without end: a stack overflow in a hand-written native file. Brush pieces already strip the brushes of their own strokes; now what they draw by name that would draw a brushed stroke (a symbol instance, or a pattern paint, also through the symbols and patterns those use) is left out too, in one place (`stroke_pieces`, which every renderer and exporter calls with the document). Probed the rest of item 2 of the depth follow-up with hand-written hostile inputs, kept as tests: graph designs of deep art and of a symbol holding itself, Affinity groups nested to and past both of its reader's caps, DXF blocks inserting themselves, EMF/WMF saving states without end, and plug-in manifests and output nested 30,000 to 100,000 deep. None of them overflowed. --- crates/brush/src/lib.rs | 94 ++++++++++++++- crates/brush/src/tests.rs | 128 ++++++++++++++------ crates/cad/src/scene.rs | 2 +- crates/doc/src/swatches.rs | 4 +- crates/engine/src/cmd/pathops.rs | 2 +- crates/engine/tests/import_fuzz.rs | 182 +++++++++++++++++++++++++++++ crates/eps/src/scene.rs | 2 +- crates/metafile/src/scene.rs | 2 +- crates/pdf/src/export.rs | 2 +- crates/render/src/brush_fx.rs | 2 +- crates/svg/src/export.rs | 2 +- 11 files changed, 370 insertions(+), 52 deletions(-) diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 339d0ec5a..91523163e 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -16,6 +16,7 @@ mod defaults; pub mod track; mod warp; +use std::collections::HashMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; @@ -557,10 +558,11 @@ fn tolerance(scale: f64) -> f64 { (0.02 * scale).clamp(0.01, 0.25) } -/// Filled art for one brushed stroke along `bp`. Pieces are in document space with id 0; their -/// appearances carry no brushes (so they never recurse). Stroke-level opacity/blend are the -/// caller's job (`StrokeLayer::opacity`). -pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec { +/// Filled art for one brushed stroke along `bp`, in `doc`. Pieces are in document space with id +/// 0, and never draw a brushed stroke (so they never recurse): their own strokes carry no brush, +/// and what they draw by name that would draw one is left out ([`leave_out_brushed`]). +/// Stroke-level opacity/blend are the caller's job (`StrokeLayer::opacity`). +pub fn stroke_pieces(doc: &Document, brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec { let w = if stroke.width > 0.0 { stroke.width } else { 1.0 }; let mut out = match &brush.kind { BrushKind::Calligraphic(c) => calli::calligraphic(c, bp, w, stroke, &brush.name), @@ -573,9 +575,91 @@ pub fn stroke_pieces(brush: &Brush, bp: &BezPath, stroke: &StrokeLayer) -> Vec = (bool, &'a str); + +/// What [`draws_brushed`] found of each symbol and pattern so far. +type Known = HashMap<(bool, String), bool>; + +/// The symbol an instance draws and the patterns that paint `n` (not its children). +fn named(n: &Node) -> impl Iterator> { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => Some((false, symbol.as_str())), + _ => None, + }; + let patterns = vectorcraft_doc::swatches::node_paints(n).filter_map(|p| match p { + Paint::Pattern { pattern, .. } => Some((true, pattern.as_str())), + _ => None, + }); + symbol.into_iter().chain(patterns) +} + +/// Does drawing the symbol or pattern `def` of `doc` draw a brushed stroke: in its art, or in +/// the art of the symbols and patterns that uses (and so on)? `known`: the answers so far. +fn draws_brushed(doc: &Document, def: Named<'_>, known: &mut Known) -> bool { + let key = (def.0, def.1.to_string()); + if let Some(&answer) = known.get(&key) { + return answer; + } + let art = |(pattern, name): Named<'_>| -> Vec<&Node> { + match pattern { + false => doc.symbols.iter().filter(|s| s.name == name).take(1).map(|s| &*s.art).collect(), + true => doc.pattern(name).map(|p| p.art.iter().map(|n| &**n).collect()).unwrap_or_default(), + } + }; + // Without recursion, each one looked at once: they may use one another without end. + let (mut seen, mut stack) = (std::collections::HashSet::from([key.clone()]), art(def)); + let mut answer = false; + while let Some(n) = stack.pop() { + if has_brush(n) { + answer = true; + break; + } + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| &**c)); + for other in named(n) { + if seen.insert((other.0, other.1.to_string())) { + stack.extend(art(other)); + } + } + } + known.insert(key, answer); + answer +} + +/// Leave out of brush pieces `nodes` what they draw by name that draws a brushed stroke +/// ([`draws_brushed`]): such an instance goes, such a pattern paint becomes no paint. A symbol or +/// pattern holding art stroked with the brush itself would draw the brush again, without end. +fn leave_out_brushed(doc: &Document, nodes: &mut Vec, known: &mut Known) { + fn walk(doc: &Document, n: &mut Node, known: &mut Known) { + for paint in vectorcraft_doc::swatches::node_paints_mut(n) { + if let Paint::Pattern { pattern, .. } = paint + && draws_brushed(doc, (true, pattern.as_str()), known) + { + *paint = Paint::None; + } + } + if let Some(children) = n.children_mut() { + children.retain(|c| !matches!(&c.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known))); + for c in children.iter_mut() { + walk(doc, Arc::make_mut(c), known); + } + } + if n.extras().mask.is_some() + && let Some(mask) = n.extras_mut().mask.as_mut() + { + walk(doc, Arc::make_mut(&mut mask.art), known); + } + } + nodes.retain(|n| !matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if draws_brushed(doc, (false, symbol.as_str()), known))); + for n in nodes.iter_mut() { + walk(doc, n, known); + } +} + /// Pieces for every brushed stroke of `n` (a path or compound path), in appearance order, each /// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed. pub fn node_pieces(doc: &Document, n: &Node) -> Option> { @@ -591,7 +675,7 @@ pub fn node_pieces(doc: &Document, n: &Node) -> Option> { continue; } any = true; - for mut p in stroke_pieces(b, &bp, st) { + for mut p in stroke_pieces(doc, b, &bp, st) { p.opacity *= st.opacity; if st.blend != vectorcraft_color::BlendMode::Normal { p.blend = st.blend; diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index b012fb97f..94e9e6014 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -37,7 +37,7 @@ fn all_points(n: &Node, out: &mut Vec) { #[test] fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() { - let out = stroke_pieces(&calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert_eq!(out.len(), 1); let b = bounds(&out); assert!(near(b.height(), 10.0, 0.05), "{b:?}"); @@ -46,30 +46,30 @@ fn calligraphic_vertical_nib_is_full_width_across_horizontal_stroke() { #[test] fn calligraphic_flat_nib_is_thin_along_its_angle() { - let out = stroke_pieces(&calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert!(near(bounds(&out).height(), 2.0, 0.05), "{:?}", bounds(&out)); } #[test] fn calligraphic_width_at_45_degrees_matches_ellipse_support() { let (a, b) = (5.0_f64, 1.0_f64); - let out = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); let expect = 2.0 * ((a * a + b * b) / 2.0).sqrt(); assert!(near(bounds(&out).height(), expect, 0.05), "{} vs {expect}", bounds(&out).height()); // A vertical stroke with the same nib has the same width (45° is symmetric). - let v = stroke_pieces(&calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0)); + let v = stroke_pieces(&Document::new(1.0, 1.0), &calli(45.0, 20.0, 10.0), &line(0.0, 0.0, 0.0, 100.0), &stroke(1.0)); assert!(near(bounds(&v).width(), expect, 0.05)); } #[test] fn calligraphic_scales_with_stroke_weight() { - let out = stroke_pieces(&calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(90.0, 100.0, 3.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(2.0)); assert!(near(bounds(&out).height(), 6.0, 0.05)); } #[test] fn round_nib_is_direction_independent() { - let out = stroke_pieces(&calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 4.0), &line(0.0, 0.0, 60.0, 80.0), &stroke(1.0)); let b = bounds(&out); // Round caps: the bounds grow by the radius on every side. assert!(near(b.x0, -2.0, 0.05) && near(b.y1, 82.0, 0.05), "{b:?}"); @@ -78,7 +78,7 @@ fn round_nib_is_direction_independent() { #[test] fn closed_calligraphic_makes_a_ring() { let bp = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); - let out = stroke_pieces(&calli(0.0, 100.0, 6.0), &bp, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &calli(0.0, 100.0, 6.0), &bp, &stroke(1.0)); let pd = out[0].path_data().unwrap(); assert_eq!(pd.subpaths.len(), 2); let poly_area = |sp: &vectorcraft_geom::SubPath| { @@ -102,12 +102,14 @@ fn rect_art(w: f64, h: f64) -> Node { #[test] fn art_brush_stretches_to_path_length() { - let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0)); + let out = + stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(1.0)); let b = bounds(&out); assert!(near(b.x0, 20.0, 1e-6) && near(b.x1, 220.0, 1e-6), "{b:?}"); assert!(near(b.height(), 4.0, 1e-6)); // 3 pt stroke → 3× wider. - let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0)); + let out = + stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &line(20.0, 50.0, 220.0, 50.0), &stroke(3.0)); assert!(near(bounds(&out).height(), 12.0, 1e-6)); } @@ -120,7 +122,7 @@ fn art_brush_arc_length_mapping_is_linear() { let mut spacer = frame.clone(); spacer.appearance = Appearance::default().into(); let art = Node::group(NodeId(0), vec![std::sync::Arc::new(spacer), std::sync::Arc::new(marker)]); - let out = stroke_pieces(&art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(art, ArtScale::Stretch), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); let g = &out[0]; let m = g.children().unwrap()[1].geometric_bounds().unwrap(); assert!(near(m.x0, 150.0, 1e-6) && near(m.x1, 180.0, 1e-6), "{m:?}"); @@ -128,7 +130,8 @@ fn art_brush_arc_length_mapping_is_linear() { #[test] fn art_brush_proportional_keeps_aspect() { - let out = stroke_pieces(&art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); + let out = + stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(100.0, 4.0), ArtScale::Proportional), &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); assert!(near(bounds(&out).height(), 8.0, 1e-6)); } @@ -139,7 +142,12 @@ fn art_brush_between_guides_keeps_ends() { Node::path(NodeId(0), shapes::rectangle(Rect::new(80.0, -4.0, 100.0, 4.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); let shaft = rect_art(80.0, 2.0); let art = Node::group(NodeId(0), vec![std::sync::Arc::new(shaft), std::sync::Arc::new(head)]); - let out = stroke_pieces(&art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), &line(0.0, 0.0, 300.0, 0.0), &stroke(1.0)); + let out = stroke_pieces( + &Document::new(1.0, 1.0), + &art_brush(art, ArtScale::BetweenGuides { start: 0.0, end: 0.8 }), + &line(0.0, 0.0, 300.0, 0.0), + &stroke(1.0), + ); let h = out[0].children().unwrap()[1].geometric_bounds().unwrap(); assert!(near(h.x0, 280.0, 1e-6) && near(h.x1, 300.0, 1e-6), "{h:?}"); } @@ -154,7 +162,7 @@ fn art_brush_follows_curves() { bp_open.push(*el); } } - let out = stroke_pieces(&art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &art_brush(rect_art(10.0, 4.0), ArtScale::Stretch), &bp_open, &stroke(1.0)); let mut pts = vec![]; all_points(&out[0], &mut pts); assert!(pts.len() > 40, "long edges are subdivided so they bend: {}", pts.len()); @@ -178,13 +186,13 @@ fn scatter_brush(spacing: f64) -> Brush { #[test] fn scatter_count_and_spacing() { - let out = stroke_pieces(&scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(100.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert_eq!(out.len(), 10); let centres: Vec = out.iter().map(|n| n.geometric_bounds().unwrap().center().x).collect(); for (i, c) in centres.iter().enumerate() { assert!(near(*c, 5.0 + 10.0 * i as f64, 1e-6), "{centres:?}"); } - let out = stroke_pieces(&scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &scatter_brush(200.0), &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert_eq!(out.len(), 5); } @@ -201,8 +209,8 @@ fn scatter_is_deterministic_and_randomised() { }), }; let p = line(0.0, 0.0, 300.0, 0.0); - let a1 = stroke_pieces(&b, &p, &stroke(1.0)); - let a2 = stroke_pieces(&b, &p, &stroke(1.0)); + let a1 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0)); + let a2 = stroke_pieces(&Document::new(1.0, 1.0), &b, &p, &stroke(1.0)); assert_eq!(a1, a2); let ys: Vec = a1.iter().map(|n| n.geometric_bounds().unwrap().center().y).collect(); assert!(ys.iter().any(|y| y.abs() > 0.5), "scatter offsets copies: {ys:?}"); @@ -228,21 +236,21 @@ fn pattern_brush(fit: PatternFit, corners: bool) -> Brush { #[test] fn pattern_tile_count_by_fit() { let p = line(0.0, 0.0, 100.0, 0.0); - assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10); + assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(1.0)).len(), 10); let p2 = line(0.0, 0.0, 104.0, 0.0); - let st = stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0)); + let st = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p2, &stroke(1.0)); assert_eq!(st.len(), 10); assert!(near(bounds(&st).width(), 104.0, 1e-6), "stretch fills the path exactly"); - let sp = stroke_pieces(&pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0)); + let sp = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::AddSpace, false), &line(0.0, 0.0, 109.0, 0.0), &stroke(1.0)); assert_eq!(sp.len(), 10); assert!(near(sp[0].geometric_bounds().unwrap().width(), 10.0, 1e-6), "add space keeps tile size"); - assert_eq!(stroke_pieces(&pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5); + assert_eq!(stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, false), &p, &stroke(2.0)).len(), 5); } #[test] fn pattern_places_corner_tiles() { let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); - let out = stroke_pieces(&pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pattern_brush(PatternFit::Stretch, true), &sq, &stroke(1.0)); let corners = out .iter() .filter( @@ -257,7 +265,7 @@ fn pattern_places_corner_tiles() { #[test] fn bristle_makes_translucent_strands() { let b = Brush { name: "b".into(), kind: BrushKind::Bristle(Bristle::default()) }; - let out = stroke_pieces(&b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 200.0, 0.0), &stroke(1.0)); assert!(out.len() >= 5, "{}", out.len()); assert!(out.iter().all(|n| n.opacity < 1.0 && n.opacity > 0.0)); let bb = bounds(&out); @@ -270,7 +278,7 @@ fn tints_colorization_uses_stroke_colour() { let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") }; a.colorization = Colorization::Tints; let b = Brush { name: "t".into(), kind: BrushKind::Art(a) }; - let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0)); assert_eq!(out[0].appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); let mut white = rect_art(1.0, 1.0); white.appearance.set_fill(Paint::solid(Color::WHITE)); @@ -310,7 +318,7 @@ fn brushes_serde_round_trip() { fn every_default_brush_draws_something() { let bp = preview_path(120.0, 24.0); for b in defaults() { - let out = stroke_pieces(b, &bp, &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), b, &bp, &stroke(1.0)); assert!(!out.is_empty(), "{}", b.name); let bb = bounds(&out); assert!(bb.width() > 50.0 && bb.width() < 200.0, "{}: {bb:?}", b.name); @@ -365,20 +373,20 @@ fn pressed(points: Vec<(f64, f64)>) -> StrokeLayer { fn pressure_maps_to_size_along_the_stroke() { let b = pressure_calli(10.0, 4.0); let light_to_heavy = pressed(vec![(0.0, 0.0), (1.0, 1.0)]); - let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &light_to_heavy); // Lightest: 6 pt (half 3); heaviest: 14 pt (half 7); in between the pressure there. assert!(near(half_width(&out, 0.5, 1.5), 3.0, 0.1), "{}", half_width(&out, 0.5, 1.5)); assert!(near(half_width(&out, 98.5, 99.5), 7.0, 0.1), "{}", half_width(&out, 98.5, 99.5)); assert!(near(half_width(&out, 49.5, 50.5), 5.0, 0.1), "{}", half_width(&out, 49.5, 50.5)); // The stroke weight scales it. let heavy = StrokeLayer { width: 2.0, ..light_to_heavy }; - let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &heavy); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &heavy); assert!(near(half_width(&out, 98.5, 99.5), 14.0, 0.2)); // No pressure recorded (a mouse, or a path the brush was applied to): the size itself. - let out = stroke_pieces(&b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); assert!(near(bounds(&out).height(), 10.0, 0.05), "{:?}", bounds(&out)); // Kept within the valid range: 2 ± 4 never goes below nothing. - let out = stroke_pieces(&pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)])); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(2.0, 4.0), &line(0.0, 0.0, 100.0, 0.0), &pressed(vec![(0.0, 0.0), (1.0, 0.0)])); assert!(!out.is_empty() && bounds(&out).height() < 0.2, "{:?}", bounds(&out)); } @@ -389,7 +397,7 @@ fn pressure_varies_angle_and_roundness() { let horizontal = line(0.0, 0.0, 100.0, 0.0); let draw = |c: Calligraphic, p: f64| { let b = Brush { name: "a".into(), kind: BrushKind::Calligraphic(c) }; - bounds(&stroke_pieces(&b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height() + bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &horizontal, &pressed(vec![(0.0, p), (1.0, p)]))).height() }; use Variation::{Fixed, Pressure}; // Angle 0 ± 90: heavy pressure stands the flat nib up across the stroke. @@ -399,7 +407,7 @@ fn pressure_varies_angle_and_roundness() { let vertical = Calligraphic { angle: 90.0, ..c([Fixed, Pressure, Fixed]) }; let wide = |p| { let b = Brush { name: "r".into(), kind: BrushKind::Calligraphic(vertical.clone()) }; - bounds(&stroke_pieces(&b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width() + bounds(&stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 0.0, 100.0), &pressed(vec![(0.0, p), (1.0, p)]))).width() }; assert!(near(wide(0.0), 0.1, 0.05), "{}", wide(0.0)); assert!(near(wide(1.0), 10.0, 0.05), "{}", wide(1.0)); @@ -421,12 +429,18 @@ fn brushes_without_modes_vary_randomly_as_before() { ..old.clone() }; let bp = preview_path(120.0, 24.0); - assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&explicit, &bp, &stroke(1.0))); + assert_eq!( + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)), + stroke_pieces(&Document::new(1.0, 1.0), &explicit, &bp, &stroke(1.0)) + ); // Fixed ignores the variation; Random still draws a different size per stroke. let fixed = Brush { kind: BrushKind::Calligraphic(Calligraphic { modes: Some([Variation::Fixed; 3]), ..c.clone() }), ..old.clone() }; - assert!(near(bounds(&stroke_pieces(&fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05)); + assert!(near(bounds(&stroke_pieces(&Document::new(1.0, 1.0), &fixed, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0))).height(), 8.0, 0.05)); // Recorded pressure changes nothing for a brush that doesn't use it. - assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)]))); + assert_eq!( + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)), + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &pressed(vec![(0.0, 0.0), (1.0, 1.0)])) + ); // Modes are saved by name. assert_eq!(serde_json::to_value(Variation::ALL).unwrap(), serde_json::json!(["fixed", "random", "pressure"])); assert!(Variation::ALL.iter().all(|v| Variation::parse(v.id()) == Some(*v))); @@ -438,7 +452,7 @@ fn junk_pressure_brushes_draw_finite_art() { let bp = preview_path(120.0, 24.0); for (size, var) in [(1e308, 1e308), (-1e308, 1e308), (0.0, -1e308), (f64::MAX, f64::MAX)] { for profile in [vec![], vec![(0.0, 2.0), (0.0, -1.0), (5.0, 0.5)], vec![(1.0, 1.0), (0.0, 0.0)]] { - let out = stroke_pieces(&pressure_calli(size, var), &bp, &pressed(profile)); + let out = stroke_pieces(&Document::new(1.0, 1.0), &pressure_calli(size, var), &bp, &pressed(profile)); let mut pts = vec![]; for n in &out { all_points(n, &mut pts); @@ -461,8 +475,9 @@ fn sized_scatter(lo: f64, hi: f64, mode: Variation) -> Brush { #[test] fn scatter_values_follow_their_modes() { let bp = line(0.0, 0.0, 400.0, 0.0); - let heights = - |b: &Brush, st: &StrokeLayer| -> Vec { stroke_pieces(b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() }; + let heights = |b: &Brush, st: &StrokeLayer| -> Vec { + stroke_pieces(&Document::new(1.0, 1.0), b, &bp, st).iter().map(|n| n.geometric_bounds().unwrap().height()).collect() + }; // Fixed: every copy at 50 %, whatever the second value. assert!(heights(&sized_scatter(50.0, 300.0, Variation::Fixed), &stroke(1.0)).iter().all(|h| near(*h, 2.0, 1e-6))); // Pressure: light at the start (50 %: 2 pt tall), heavy at the end (300 %: 12 pt). @@ -478,7 +493,10 @@ fn scatter_values_follow_their_modes() { let BrushKind::Scatter(sc) = &old.kind else { panic!("not a scatter brush") }; assert_eq!(Scatter { modes: None, ..sc.clone() }.modes(), [Variation::Random, Variation::Fixed, Variation::Fixed, Variation::Fixed]); let unsaved = Brush { kind: BrushKind::Scatter(Scatter { modes: None, ..sc.clone() }), ..old.clone() }; - assert_eq!(stroke_pieces(&old, &bp, &stroke(1.0)), stroke_pieces(&unsaved, &bp, &stroke(1.0))); + assert_eq!( + stroke_pieces(&Document::new(1.0, 1.0), &old, &bp, &stroke(1.0)), + stroke_pieces(&Document::new(1.0, 1.0), &unsaved, &bp, &stroke(1.0)) + ); assert!(serde_json::to_value(&unsaved).unwrap().get("modes").is_none(), "not saved when unset"); } @@ -521,3 +539,37 @@ fn art_colors_list_the_most_used_first() { assert_eq!(b.art().len(), 3, "the side and both corner tiles"); assert!(calli(0.0, 100.0, 3.0).art().is_empty()); } + +/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed +/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay. +#[test] +fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() { + use vectorcraft_doc::{PatternDef, Symbol}; + let mut doc = Document::new(100.0, 100.0); + let mut brushed = rect_art(10.0, 4.0); + brushed.appearance.items.push(vectorcraft_doc::AppearanceItem::Stroke(StrokeLayer { brush: Some("s".into()), ..stroke(1.0) })); + for (name, art) in [("plain", rect_art(10.0, 4.0)), ("brushed", brushed)] { + doc.symbols.push(Symbol { name: name.into(), art: std::sync::Arc::new(art.clone()) }); + doc.patterns.push(PatternDef::new(name, vec![std::sync::Arc::new(art)])); + } + let instance = |symbol: &str| Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: symbol.into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let painted = |pattern: &str| { + let fill = Paint::Pattern { pattern: pattern.into(), xf: vectorcraft_geom::Affine::IDENTITY }; + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 4.0)), Appearance::basic(fill, Paint::None, 0.0)) + }; + let art = Node::group(NodeId(0), [instance("plain"), instance("brushed"), painted("plain"), painted("brushed")].map(std::sync::Arc::new).into()); + let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::None, ..Default::default() }) }; + let out = stroke_pieces(&doc, &b, &line(0.0, 0.0, 100.0, 0.0), &stroke(1.0)); + assert!(!out.is_empty()); + for piece in &out { + let children = piece.children().unwrap(); + let symbols: Vec<&str> = children + .iter() + .filter_map(|c| if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { Some(symbol.as_str()) } else { None }) + .collect(); + assert_eq!(symbols, ["plain"]); + let fills: Vec = + children.iter().filter(|c| c.path_data().is_some()).filter_map(|c| c.appearance.fill().map(|f| f.paint.clone())).collect(); + assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]); + } +} diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 22fb18872..425505a72 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -506,7 +506,7 @@ impl<'a> Scene<'a> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?; - Some(vectorcraft_brush::stroke_pieces(b, bp, st)) + Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } /// A filled area as its outlines followed by a solid hatch (R12: the outlines alone). Laser diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 689124b05..19e36da80 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -226,7 +226,7 @@ fn item_paint_mut(it: &mut AppearanceItem) -> &mut Paint { } /// The paints a node holds itself (not its children): appearance items, then text runs. -pub(crate) fn node_paints(n: &Node) -> impl Iterator { +pub fn node_paints(n: &Node) -> impl Iterator { let runs = match &n.kind { NodeKind::Text(t) => t.runs.as_slice(), _ => &[], @@ -235,7 +235,7 @@ pub(crate) fn node_paints(n: &Node) -> impl Iterator { } /// Mutable [`node_paints`], in the same order. -pub(crate) fn node_paints_mut(n: &mut Node) -> impl Iterator { +pub fn node_paints_mut(n: &mut Node) -> impl Iterator { let runs = match &mut n.kind { NodeKind::Text(t) => t.runs.as_mut_slice(), _ => &mut [], diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 4169add6b..1538c3614 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -594,7 +594,7 @@ pub(crate) fn outlined_stroke( st: &StrokeLayer, ) -> Option { if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { - let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st); + let mut pieces = vectorcraft_brush::stroke_pieces(d, b, &path.to_bezpath(), st); let art = match pieces.len() { 0 => return None, 1 => pieces.remove(0), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index bd79a7b2c..c3e2fb113 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2881,6 +2881,188 @@ fn deeply_nested_dxf_blocks_are_read_within_the_cap() { }); } +#[test] +fn dxf_blocks_inserting_themselves_are_left_out() { + on_a_small_stack(|| { + let pair = |c: i32, v: &str| (c, v.to_string()); + // A line, then an insert of `inserts`. + let block = |name: &str, inserts: &str| { + let line = [pair(0, "LINE"), pair(10, "0"), pair(20, "0"), pair(11, "5"), pair(21, "5")]; + let insert = [pair(0, "INSERT"), pair(2, inserts), pair(10, "1"), pair(20, "1"), pair(0, "ENDBLK")]; + [pair(0, "BLOCK"), pair(2, name), pair(10, "0"), pair(20, "0")].into_iter().chain(line).chain(insert).collect::>() + }; + let mut pairs = vec![pair(0, "SECTION"), pair(2, "BLOCKS")]; + pairs.extend([block("SELF", "SELF"), block("A", "B"), block("B", "A")].concat()); + pairs.extend([pair(0, "ENDSEC"), pair(0, "SECTION"), pair(2, "ENTITIES")]); + for name in ["SELF", "A"] { + pairs.extend([pair(0, "INSERT"), pair(2, name), pair(10, "0"), pair(20, "0")]); + } + pairs.extend([pair(0, "ENDSEC"), pair(0, "EOF")]); + let (_, warnings) = open_deep("self.dxf", dxf_text(&pairs).as_bytes()).expect("opens"); + assert!(warnings.iter().any(|w| w.contains("inside themselves")), "{warnings:?}"); + }); +} + +#[test] +fn deeply_nested_affinity_groups_are_read_within_the_cap() { + use vectorcraft_affinity::synth::{self, F, Method, tag}; + // Groups in groups: within what the reader takes, past its cap on layers (128), past its cap + // on the stream's own nesting (384), and far past both. + let files: Vec> = [20u32, 120, 180, 370, 2000] + .into_iter() + .map(|levels| { + let group = |id: u32, fields| F::Def(id, vec![tag(b"Grup")], fields); + let inner = (0..levels).fold(group(10, vec![]), |inner, i| group(11 + i, vec![(tag(b"Chld"), F::Shared(vec![inner]))])); + let spread = F::Def(2, vec![tag(b"Sprd")], vec![(tag(b"Chld"), F::Shared(vec![inner]))]); + let stream = synth::stream(&[(tag(b"DocR"), F::Def(1, vec![tag(b"DocN")], vec![(tag(b"Chld"), F::Shared(vec![spread]))]))]); + affinity_native(&stream, Method::Stored) + }) + .collect(); + on_a_small_stack(move || { + for bytes in &files { + open_deep("deep.afdesign", bytes); + } + }); +} + +#[test] +fn metafiles_saving_states_without_end_are_read_within_the_cap() { + on_a_small_stack(|| { + // A saved state, a clip and a rectangle, over and over (nothing is ever restored). + let longs = |v: [i32; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); + let emf: Vec<(u32, Vec)> = (0..50_000).flat_map(|_| [(33, vec![]), (30, longs([0, 0, 500, 500])), (43, longs([0, 0, 50, 50]))]).collect(); + open_deep("deep.emf", &emf_of(&emf)); + let shorts = |v: [i16; 4]| v.iter().flat_map(|x| x.to_le_bytes()).collect::>(); + let wmf: Vec<(u16, Vec)> = + (0..20_000).flat_map(|_| [(0x001E, vec![]), (0x0416, shorts([500, 500, 0, 0])), (0x041B, shorts([50, 50, 0, 0]))]).collect(); + open_deep("deep.wmf", &wmf_of(&wmf)); + }); +} + +#[test] +fn deeply_nested_plugin_manifests_and_output_are_errors() { + on_a_small_stack(|| { + // (A manifest may be 64 kB.) + let deep = |n: usize| format!("{}{}", "[".repeat(n), "]".repeat(n)); + assert!(vectorcraft_plugins::Manifest::parse(deep(30_000).as_bytes()).is_err()); + assert!(vectorcraft_plugins::Manifest::parse(format!("{{\"params\": {}}}", deep(30_000)).as_bytes()).is_err()); + let deep = deep(100_000); + for output in [deep.clone(), format!("{{\"objects\": {deep}}}"), format!("{{\"objects\": [{{\"kind\": \"path\", \"path\": {deep}}}]}}")] { + assert!(vectorcraft_plugins::objects::decode(output.as_bytes()).is_err()); + } + }); +} + +/// A document whose symbol `s0` and pattern `p0` are each a line stroked with the brush `loop`, +/// with an instance of the symbol and a square painted with the pattern on the layer. The brush +/// is the `kind`th of those that hold art (art, scatter, pattern), and its art is the `art`th of: +/// an instance of the symbol; that instance inside as many groups as a file holds; a square +/// painted with the pattern. So what the brush draws is drawn with the brush. +fn brushed_document(kind: usize, art: usize) -> Document { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, PatternBrush, Scatter}; + use vectorcraft_color::Paint; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, PatternDef, Symbol}; + let mut doc = nested_document(1); + let square = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 60.0, 20.0)); + let brushed = |doc: &mut Document| { + let mut look = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::rgb8(0, 0, 0)), 4.0); + for item in &mut look.items { + if let AppearanceItem::Stroke(s) = item { + s.brush = Some("loop".into()); + } + } + Node::path(doc.alloc_id(), square(), look) + }; + let instance = + |doc: &mut Document| Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let painted = |doc: &mut Document| { + let fill = Paint::Pattern { pattern: "p0".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + Node::path(doc.alloc_id(), square(), Appearance::basic(fill, Paint::None, 0.0)) + }; + let (in_symbol, in_pattern) = (brushed(&mut doc), brushed(&mut doc)); + doc.symbols.push(Symbol { name: "s0".into(), art: std::sync::Arc::new(in_symbol) }); + doc.patterns.push(PatternDef::new("p0", vec![std::sync::Arc::new(in_pattern)])); + let art = match art { + 0 => instance(&mut doc), + 1 => (0..MAX_DEPTH + 4).fold(instance(&mut doc), |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)])), + _ => painted(&mut doc), + }; + let kind = match kind { + 0 => BrushKind::Art(ArtBrush { art, ..Default::default() }), + 1 => BrushKind::Scatter(Scatter { art, ..Default::default() }), + _ => BrushKind::Pattern(PatternBrush { side: art.clone(), start: Some(art), ..Default::default() }), + }; + vectorcraft_brush::store(&mut doc, &[Brush { name: "loop".into(), kind }]); + let layer = doc.layers[0].id; + for on_layer in [instance(&mut doc), painted(&mut doc), brushed(&mut doc)] { + doc.insert(Some(layer), usize::MAX, on_layer).unwrap(); + } + doc +} + +/// `doc` saved, opened again, drawn, exported and edited: nothing overflows. +fn art_by_name_survives(what: &str, doc: Document) { + let (back, _) = open_deep("named.vectorcraft", &vectorcraft_format::save_file(&doc)).expect("opens"); + let mut s = vectorcraft_engine::Session::new(); + s.add_document(back, None); + for (cmd, p) in [ + ("select.all", json!({})), + ("document.inspect", json!({})), + ("brush.expand", json!({})), + ("edit.undo", json!({})), + ("object.expand", json!({})), + ("edit.undo", json!({})), + ("object.outlineStroke", json!({})), + ("edit.undo", json!({})), + ("graph.design", json!({"paste": "deep"})), + ("graph.create", json!({"type": "line", "x": 10, "y": 10, "width": 80, "height": 60})), + ("graph.marker", json!({"design": "deep"})), + ("graph.design", json!({"new": "again"})), + ("edit.copy", json!({})), + ("edit.paste", json!({})), + ] { + let _ = no_panic(s.execute(cmd, &p)); + assert!(!s.doc().unwrap().doc.too_deep(None), "{what}: {cmd}"); + } + for format in ["svg", "pdf", "eps", "png", "dxf", "emf"] { + let _ = no_panic(s.execute("document.export", &json!({"format": format}))); + } +} + +/// A test for each brush whose art is drawn with the brush itself ([`brushed_document`]). +macro_rules! brushes_drawn_with_themselves { + ($($test:ident: $kind:literal, $art:literal;)*) => {$( + #[test] + fn $test() { + on_a_small_stack(|| art_by_name_survives(stringify!($test), brushed_document($kind, $art))); + } + )*}; +} + +brushes_drawn_with_themselves! { + an_art_brush_of_its_own_symbol: 0, 0; + an_art_brush_of_its_own_symbol_in_deep_art: 0, 1; + an_art_brush_painted_with_its_own_pattern: 0, 2; + a_scatter_brush_of_its_own_symbol: 1, 0; + a_scatter_brush_of_its_own_symbol_in_deep_art: 1, 1; + a_scatter_brush_painted_with_its_own_pattern: 1, 2; + a_pattern_brush_of_its_own_symbol: 2, 0; + a_pattern_brush_of_its_own_symbol_in_deep_art: 2, 1; + a_pattern_brush_painted_with_its_own_pattern: 2, 2; +} + +#[test] +fn a_graph_design_of_deep_art_and_of_a_symbol_holding_itself_is_drawn() { + use vectorcraft_doc::{GraphDesign, Node, NodeKind}; + on_a_small_stack(|| { + let mut doc = symbols_document(1, 1, "s0"); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "s0".into(), xf: vectorcraft_geom::Affine::IDENTITY }); + let art = (0..MAX_DEPTH + 4).fold(instance, |inner, _| Node::group(doc.alloc_id(), vec![std::sync::Arc::new(inner)])); + doc.graph_designs.push(GraphDesign { name: "deep".into(), art: std::sync::Arc::new(art) }); + art_by_name_survives("a graph design of deep art", doc); + }); +} + /// A layer holding `levels` groups in one another around a rectangle, each with a second /// rectangle beside the next: `levels + 2` deep. fn nested_document(levels: usize) -> Document { diff --git a/crates/eps/src/scene.rs b/crates/eps/src/scene.rs index 89fff7c50..f43f840c0 100644 --- a/crates/eps/src/scene.rs +++ b/crates/eps/src/scene.rs @@ -542,7 +542,7 @@ impl<'a> Scene<'a> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; - let art = vectorcraft_brush::stroke_pieces(b, bp, st); + let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st); self.note_item(st.opacity, st.blend); self.nest += 1; for piece in &art { diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs index 99957589e..8a5d940c3 100644 --- a/crates/metafile/src/scene.rs +++ b/crates/metafile/src/scene.rs @@ -403,7 +403,7 @@ impl<'a> Scene<'a> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?; - Some(vectorcraft_brush::stroke_pieces(b, bp, st)) + Some(vectorcraft_brush::stroke_pieces(doc, b, bp, st)) } /// A placed image, decoded once per image. diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index ea9154c95..a64eb554c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1336,7 +1336,7 @@ impl Exporter<'_> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) else { return false }; - let art = vectorcraft_brush::stroke_pieces(b, bp, st); + let art = vectorcraft_brush::stroke_pieces(doc, b, bp, st); let mut pushes = 0; if st.blend != BlendMode::Normal { s.push_blend_mode(blend(st.blend)); diff --git a/crates/render/src/brush_fx.rs b/crates/render/src/brush_fx.rs index 24558df51..72dc14de0 100644 --- a/crates/render/src/brush_fx.rs +++ b/crates/render/src/brush_fx.rs @@ -107,7 +107,7 @@ impl Renderer { let pieces = match self.brushes.entries.get(&key, stamp) { Some(e) if Arc::ptr_eq(&e.brush, &brush) && e.stroke == *st && e.path == *bp => e.pieces.clone(), _ => { - let pieces = Arc::new(vectorcraft_brush::stroke_pieces(&brush, bp, st)); + let pieces = Arc::new(vectorcraft_brush::stroke_pieces(f.doc, &brush, bp, st)); let bytes = size_of::() + lru::path_bytes(bp) + pieces.iter().map(lru::node_bytes).sum::(); self.brushes.entries.insert(key, Entry { path: bp.clone(), stroke: st.clone(), brush, pieces: pieces.clone() }, bytes, stamp); pieces diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 4f8e7f883..1f60936f2 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -917,7 +917,7 @@ impl Writer<'_> { let doc = self.doc; let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); match st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) { - Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(b, bp, st)), + Some(b) => StrokePlan::Brush(vectorcraft_brush::stroke_pieces(doc, b, bp, st)), None => StrokePlan::Written(stroke::for_writer(bp, st)), } } From 991ce2c6356310dce02d48d752e8b916a9d3745c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 10:13:23 +0700 Subject: [PATCH 25/37] Importers: PDF streams the reader would fail on are refused (#1700, #1701) hayro (0.7.2 and 0.8.0 alike) inflates Flate and LZW data with no ceiling, so a few megabytes of zeros ask for gigabytes, and applies a predictor before checking its parameters: Columns 0 divides by zero (a panic, an abort on the web), a huge Columns allocates a row that large (an abort everywhere). Both happen inside the reader, before any of our limits. A file is refused ("a stream in it would decode to more than 256 MB, or its decode parameters can't be applied") when a stream's decode parameters ask for a predictor the reader can't apply, or when it decodes past 256 MB through Flate, LZW, run-length and the ASCII encodings before them, decoded here with a ceiling (only streams large enough to get there are decoded). Every object's stream is looked at once the file is open (decrypted, references followed), with the inline images of anything that can be content; object and cross-reference streams, which the reader decodes while it opens the file, are looked at first in the file's text. --- Cargo.lock | 1 + crates/engine/tests/import_fuzz.rs | 56 ++++- crates/pdf/Cargo.toml | 1 + crates/pdf/src/lib.rs | 1 + crates/pdf/src/pages.rs | 27 ++- crates/pdf/src/streams.rs | 367 +++++++++++++++++++++++++++++ 6 files changed, 447 insertions(+), 6 deletions(-) create mode 100644 crates/pdf/src/streams.rs diff --git a/Cargo.lock b/Cargo.lock index 1303758db..c5913b505 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4800,6 +4800,7 @@ dependencies = [ "vectorcraft-geom", "vectorcraft-testkit", "vectorcraft-text", + "weezl", ] [[package]] diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index c3e2fb113..cd70250eb 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2552,10 +2552,14 @@ fn deep_pdf(content: &str, resources: &str) -> Vec { /// A stream object with the entries `dict`, its `data` compressed. fn packed(dict: &str, data: &str) -> Vec { - let z = deflated(data.as_bytes()); + packed_as_is(dict, &deflated(data.as_bytes())) +} + +/// A stream object with the entries `dict`, its data `z` compressed already. +fn packed_as_is(dict: &str, z: &[u8]) -> Vec { // (A length the test gives stands.) let length = if dict.contains("/Length") { String::new() } else { format!(" /Length {}", z.len()) }; - [format!("<< {dict} /Filter /FlateDecode{length} >>\nstream\n").as_bytes(), &z, b"\nendstream"].concat() + [format!("<< {dict} /Filter /FlateDecode{length} >>\nstream\n").as_bytes(), z, b"\nendstream"].concat() } /// A page whose content is `content`, with `resources` and the objects `more` (numbered from 5). @@ -2801,6 +2805,54 @@ fn pdf_color_spaces_that_hold_themselves() { ); } +/// Spaces past what a stream may decode to (256 MB), compressed (about 260 kB). +fn spaces_past_the_cap() -> Vec { + use std::io::Write as _; + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + for _ in 0..257 { + e.write_all(&[b' '; 1 << 20]).unwrap(); + } + e.finish().unwrap() +} + +// Streams the reader would fail on (#1700, #1701): it applies a predictor before checking its +// parameters (`Columns 0` divides by zero, a huge `Columns` allocates a row that large), and +// inflates without a ceiling. + +#[test] +fn pdf_streams_the_reader_would_fail_on_are_refused() { + let zero = "/DecodeParms << /Predictor 2 /Columns 0 >>"; + let huge = "/DecodeParms << /Predictor 12 /Colors 4 /Columns 99999999999 >>"; + // An inline image whose data is deflated, written in hexadecimal so the content stays text. + let inline = |dp: &str| { + let data: String = deflated(&[0; 8]).iter().map(|b| format!("{b:02x}")).collect(); + format!("q 10 0 0 10 0 0 cm BI /W 2 /H 2 /CS /G /BPC 8 /F [/AHx /Fl] /DP [null {dp}] ID {data}> EI Q") + }; + let form = |dict: &str, content: &str| pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed(&format!("{FORM} {dict}"), content)]); + let files = [ + ("a form with Columns 0", form(zero, "0 0 5 5 re f")), + ("a form with a huge Columns", form(huge, "0 0 5 5 re f")), + ("an inline image with Columns 0", deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>")), + ("an inline image with a huge Columns", deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>")), + ("an inline image in a compressed form", form("", &inline("<< /Predictor 2 /Columns 0 >>"))), + ("an object stream with Columns 0", pdf_with_object_stream("[0 0 100 100]", zero)), + ("a form of spaces past the cap", pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())])), + ]; + on_a_small_stack(move || { + for (what, bytes) in &files { + for name in ["hostile.pdf", "hostile.ai"] { + let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string()); + assert!(e.as_deref().is_some_and(|e| e.contains("would decode to more than 256 MB")), "{what} as {name}: {e:?}"); + } + } + }); + // Within range, the same files open. + let fine = "/DecodeParms << /Predictor 1 /Columns 0 >>"; + for bytes in [form(fine, "0 0 5 5 re f"), deep_pdf(&inline("<< /Predictor 1 >>"), "<< >>")] { + vectorcraft_engine::cmd::fileio::load("fine.pdf", &bytes).expect("opens"); + } +} + #[test] fn pdf_art_that_draws_itself() { let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>"; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index e53fc21b8..ea9e7c671 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -27,6 +27,7 @@ hayro-syntax = "0.8.0" hayro-interpret = { version = "0.8.0", default-features = false, features = ["embed-fonts", "embed-cmaps"] } log = { workspace = true } smallvec = "1" +weezl = "0.1" serde = { workspace = true } # PDF security (encrypt.rs): pure Rust, MIT OR Apache-2.0, builds for wasm. aes = "0.8" diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 636b6712f..c69d80302 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -64,6 +64,7 @@ mod post; mod presets; mod print; mod settings; +mod streams; mod syntax; pub use editing::{EDITING_FILE, Editing, LEGACY_EDITING_FILE, editing, editing_with}; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 9d38e43a1..b56f2cd19 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -114,6 +114,21 @@ fn procedures_too_deep(o: &Object<'_>) -> bool { }) } +/// The error of a file with a stream the reader would fail on ([`crate::streams`]). +const STREAM_NOTE: &str = "a stream in it would decode to more than 256 MB, or its decode parameters can't be applied"; + +/// The error of a file [`nests_without_end`]. +const NESTED_NOTE: &str = "its functions or colour spaces are built on one another without end"; + +/// Why `pdf` is refused, if it is: a stream the reader would fail on (its own checks come first: +/// the calculator functions are decoded below), or [`nests_without_end`]. +fn hostile(pdf: &Pdf) -> Option<&'static str> { + if pdf.objects().into_iter().any(|o| matches!(&o, Object::Stream(s) if crate::streams::unsafe_stream(s))) { + return Some(STREAM_NOTE); + } + nests_without_end(pdf).then_some(NESTED_NOTE) +} + /// Does `pdf` hold functions or colour spaces built on one another past [`MAX_LINKS`] (or on /// themselves), or a calculator function nested past [`MAX_PROCEDURES`]? Every object is looked /// at, wherever it is used from: the interpreter builds them from resources, shadings, images, @@ -142,11 +157,15 @@ fn nests_without_end(pdf: &Pdf) -> bool { /// dictionaries 64 deep and no deeper, wherever they are: the file's text, an object stream, the /// content of a page, form, pattern or glyph; and follows a page tree or a chain of object streams /// once). What it reads beyond that is left out, as an object it can't read. What the interpreter -/// builds by recursion with no cap is refused here ([`nests_without_end`]). +/// builds by recursion with no cap, and streams it would decode without a ceiling or with a +/// predictor it can't apply, are refused here ([`hostile`], [`crate::streams`]). pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result { if is_postscript(bytes) { return Err(PdfError::PostScript); } + if crate::streams::unsafe_file_streams(bytes) { + return Err(PdfError::Parse(STREAM_NOTE.into())); + } let password = password.unwrap_or_default(); let read = |pw: &str| Pdf::new_with_password(bytes.to_vec(), pw); read(password) @@ -165,9 +184,9 @@ pub(crate) fn open(bytes: &[u8], password: Option<&str>) -> Result Err(PdfError::Parse("its functions or colour spaces are built on one another without end".into())), - false => Ok(pdf), + .and_then(|pdf| match (0..2).find_map(|_| hostile(&pdf)) { + Some(why) => Err(PdfError::Parse(why.into())), + None => Ok(pdf), }) } diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs new file mode 100644 index 000000000..61a6fcb9f --- /dev/null +++ b/crates/pdf/src/streams.rs @@ -0,0 +1,367 @@ +//! Streams looked at before the PDF reader (hayro) decodes them. It inflates Flate and LZW data +//! with no ceiling, so a few megabytes can ask for gigabytes (#1700), and it applies a predictor +//! before checking its parameters: `Columns 0` divides by zero, a huge `Columns` allocates a row +//! that large whatever the stream holds (#1701). A file with such a stream is refused instead. + +use std::borrow::Cow; +use std::io::Read; + +use hayro_syntax::content::TypedIter; +use hayro_syntax::content::ops::TypedInstruction; +use hayro_syntax::object::{Array, Dict, FromBytes, MaybeRef, Name, Object, Stream}; + +/// The most a stream may decode to: 256 MB, the pixels of the largest image the app reads (64 +/// million, 4 bytes each). +pub(crate) const MAX_DECODED: usize = 256 << 20; + +/// The longest row a predictor may have, in bytes. +const MAX_ROW: usize = 1 << 24; + +/// A stream that decodes to more than [`MAX_DECODED`]. +struct TooBig; + +/// The filters of the stream with dictionary `dict`, by name (`Filter` or `F`: a name or an array). +fn filters<'a>(dict: &Dict<'a>) -> Vec> { + [&b"Filter"[..], b"F"] + .into_iter() + .find_map(|key| dict.get::>(key).map(|n| vec![n]).or_else(|| dict.get::>(key).map(|a| a.iter::>().collect()))) + .unwrap_or_default() +} + +/// Every decode parameter dictionary of the stream with dictionary `dict`, as the reader may take +/// them (`DecodeParms` or `DP`: a dictionary, or an array with one for each filter). +fn params<'a>(dict: &Dict<'a>) -> Vec> { + let mut all = vec![]; + for key in [&b"DecodeParms"[..], b"DP"] { + all.extend(dict.get::>(key)); + all.extend(dict.get::>(key).into_iter().flat_map(|a| a.iter::>().filter_map(|o| o.into_dict()).collect::>())); + } + all +} + +/// Would the reader's predictor fail on `params`? It reads `Colors` and `BitsPerComponent` as +/// bytes and `Columns` as a size, multiplies the first two as bytes, divides by the length of a row +/// and allocates one before it checks anything. +fn bad_predictor(params: &Dict<'_>) -> bool { + if params.get::(b"Predictor").unwrap_or(1) == 1 { + return false; + } + let colors = usize::from(params.get::(b"Colors").unwrap_or(1)); + let bits = usize::from(params.get::(b"BitsPerComponent").unwrap_or(8)); + let columns = params.get::(b"Columns").unwrap_or(1); + let pixel = colors * bits; + let row = columns.checked_mul(pixel).map(|b| b.div_ceil(8)); + !(matches!(bits, 1 | 2 | 4 | 8 | 16) && colors > 0 && pixel <= 255 && columns > 0 && row.is_some_and(|r| r <= MAX_ROW)) +} + +/// `data` inflated, up to one byte past [`MAX_DECODED`] (zlib, or raw deflate as the reader also +/// takes); `None` when it is damaged before that. +fn inflate(data: &[u8]) -> Option> { + let read = |mut r: Box| { + let mut out = vec![]; + // (Damaged data: what came out before the damage still counts.) + let done = r.by_ref().take(MAX_DECODED as u64 + 1).read_to_end(&mut out).is_ok(); + (done || out.len() > MAX_DECODED).then_some(out) + }; + read(Box::new(flate2::read::ZlibDecoder::new(data))).or_else(|| read(Box::new(flate2::read::DeflateDecoder::new(data)))) +} + +/// `data` decoded from LZW, up to one byte past [`MAX_DECODED`]. `early`: the code length grows +/// one code early (`EarlyChange`, 1 by default). +fn lzw(data: &[u8], early: bool) -> Option> { + use weezl::{BitOrder, LzwStatus, decode::Decoder}; + let mut decoder = if early { Decoder::with_tiff_size_switch(BitOrder::Msb, 8) } else { Decoder::new(BitOrder::Msb, 8) }; + let (mut out, mut chunk, mut input) = (vec![], vec![0; 1 << 16], data); + while out.len() <= MAX_DECODED { + let r = decoder.decode_bytes(input, &mut chunk); + input = input.get(r.consumed_in..).unwrap_or_default(); + out.extend_from_slice(chunk.get(..r.consumed_out).unwrap_or_default()); + match r.status { + Ok(LzwStatus::Ok) => {} + Ok(LzwStatus::Done | LzwStatus::NoProgress) => break, + Err(_) => return (out.len() > MAX_DECODED).then_some(out), + } + } + Some(out) +} + +/// `data` decoded from run-length encoding, up to one byte past [`MAX_DECODED`]. +fn run_length(data: &[u8]) -> Vec { + let (mut out, mut at) = (vec![], 0); + while let Some(&n) = data.get(at) + && n != 128 + && out.len() <= MAX_DECODED + { + if n < 128 { + let take = usize::from(n) + 1; + out.extend_from_slice(data.get(at + 1..).unwrap_or_default().get(..take).unwrap_or_default()); + at += 1 + take; + } else { + out.extend(std::iter::repeat_n(data.get(at + 1).copied().unwrap_or(0), 257 - usize::from(n))); + at += 2; + } + } + out +} + +/// `data` decoded from hexadecimal digits (white space skipped, up to `>`). +fn hex(data: &[u8]) -> Vec { + let digits: Vec = data.iter().take_while(|c| **c != b'>').filter_map(|c| char::from(*c).to_digit(16)).map(|d| d as u8).collect(); + digits.chunks(2).map(|p| (p.first().copied().unwrap_or(0) << 4) | p.get(1).copied().unwrap_or(0)).collect() +} + +/// `data` decoded from base-85 (white space skipped, `z` for four zeros, up to `~>`). +fn base85(data: &[u8]) -> Vec { + let (mut out, mut group) = (vec![], vec![]); + let flush = |group: &mut Vec, out: &mut Vec| { + let n = group.len(); + if n > 1 { + group.resize(5, 84); + let v = group.iter().fold(0u32, |v, d| v.wrapping_mul(85).wrapping_add(*d)); + out.extend_from_slice(v.to_be_bytes().get(..n - 1).unwrap_or_default()); + } + group.clear(); + }; + for &c in data.iter().take_while(|c| **c != b'~') { + match c { + b'z' if group.is_empty() => out.extend([0; 4]), + b'!'..=b'u' => { + group.push(u32::from(c - b'!')); + if group.len() == 5 { + flush(&mut group, &mut out); + } + } + _ => {} + } + } + flush(&mut group, &mut out); + out +} + +/// The most data of `len` bytes can grow to through `filters`: 1,032 times through Flate (its +/// largest ratio), 3,641 through LZW (4,096 bytes from a 9-bit code), 64 through run-length. +fn most_after(len: usize, filters: &[Name<'_>]) -> u128 { + filters.iter().fold(len as u128, |n, f| { + n.saturating_mul(match f.as_ref() { + b"FlateDecode" | b"Fl" => 1032, + b"LZWDecode" | b"LZW" => 3641, + b"RunLengthDecode" | b"RL" => 64, + _ => 1, + }) + }) +} + +/// `data` (of the stream with dictionary `dict`) through its filters, as far as this reads them: +/// Flate, LZW, run-length and the ASCII encodings. Not decoded when it can't decode past +/// [`MAX_DECODED`] and `keep` doesn't ask for it, or when an image codec or damaged data comes +/// first (an image's own decoder knows its size). +fn decode(data: &[u8], dict: &Dict<'_>, keep: bool) -> Result>, TooBig> { + let filters = filters(dict); + if !keep && most_after(data.len(), &filters) <= MAX_DECODED as u128 { + return Ok(None); + } + let params = params(dict); + let mut data = Cow::Borrowed(data); + for (i, f) in filters.iter().enumerate() { + let early = params.get(i).and_then(|p| p.get::(b"EarlyChange")).is_none_or(|e| e != 0); + let out = match f.as_ref() { + b"FlateDecode" | b"Fl" => inflate(&data), + b"LZWDecode" | b"LZW" => lzw(&data, early), + b"RunLengthDecode" | b"RL" => Some(run_length(&data)), + b"ASCIIHexDecode" | b"AHx" => Some(hex(&data)), + b"ASCII85Decode" | b"A85" => Some(base85(&data)), + _ => None, + }; + match out { + Some(out) if out.len() > MAX_DECODED => return Err(TooBig), + Some(out) => data = Cow::Owned(out), + None => return Ok(None), + } + } + Ok(Some(data.into_owned())) +} + +/// May the stream with dictionary `dict` be content (of a page, form, pattern or glyph), which can +/// hold inline images? Not an image, font, profile, function, metadata or object stream. +fn maybe_content(dict: &Dict<'_>) -> bool { + let named = |key: &[u8], ok: &[&[u8]]| dict.get::>(key).is_none_or(|n| ok.contains(&n.as_ref())); + named(b"Subtype", &[b"Form"]) + && named(b"Type", &[b"XObject", b"Pattern"]) + && ![&b"Length1"[..], b"Length2", b"Length3", b"FunctionType", b"N"].iter().any(|k| dict.contains_key(k)) +} + +/// Would the reader fail on `stream` (or on an inline image in it, when it is content): a +/// predictor it can't apply, or more than [`MAX_DECODED`] decoded? +pub(crate) fn unsafe_stream(stream: &Stream<'_>) -> bool { + unsafe_data(stream, true) +} + +/// [`unsafe_stream`], looking for inline images in it when `inside` (not in an inline image's own +/// data: it would be read again for each inline image a hostile one nests). +fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool { + let dict = stream.dict(); + if params(dict).iter().any(bad_predictor) { + return true; + } + let content = inside && maybe_content(dict); + match decode(&stream.raw_data(), dict, content) { + Err(TooBig) => true, + Ok(Some(data)) if content && data.windows(2).any(|w| w == b"ID") => { + let mut ops = TypedIter::new(&data); + while let Some(op) = ops.next() { + if let TypedInstruction::InlineImage(image) = op + && unsafe_data(image.0, false) + { + return true; + } + } + false + } + Ok(_) => false, + } +} + +/// Would the reader fail on a stream it decodes while it opens `pdf`, before anything else can +/// look at it: an object stream or a cross-reference stream (read here from the file's text)? +/// Their decode parameters must be in their dictionary, as the reader can't be asked for others +/// yet. Encrypted object streams are looked at once the file is open (their data is ciphered +/// here). +pub(crate) fn unsafe_file_streams(pdf: &[u8]) -> bool { + let find = |from: usize, what: &[u8]| pdf.get(from..).and_then(|rest| rest.windows(what.len()).position(|w| w == what)).map(|at| from + at); + let mut from = 0; + while let Some(at) = find(from, b"stream") { + from = at + 6; + if at >= 3 && pdf.get(at - 3..at) == Some(b"end") { + continue; + } + // The dictionary before it: back from its `>>` to the `<<` that opens it. + let Some(close) = pdf.get(..at).and_then(|head| head.iter().rposition(|c| !c.is_ascii_whitespace())).map(|i| i + 1) else { continue }; + if close < 2 || pdf.get(close - 2..close) != Some(b">>") { + continue; + } + let (mut depth, mut open, mut i) = (0usize, None, close); + while i >= 2 && close - i < 1 << 16 { + match pdf.get(i - 2..i) { + Some(b">>") => (depth, i) = (depth + 1, i - 2), + Some(b"<<") => { + depth -= 1; + i -= 2; + if depth == 0 { + open = Some(i); + break; + } + } + _ => i -= 1, + } + } + let Some(dict) = open.and_then(|o| pdf.get(o..close)).and_then(Dict::from_bytes) else { continue }; + if !dict.get::>(b"Type").is_some_and(|t| matches!(t.as_ref(), b"ObjStm" | b"XRef")) { + continue; + } + let indirect = [&b"DecodeParms"[..], b"DP"].iter().any(|k| matches!(dict.get_raw::>(k), Some(MaybeRef::Ref(_)))); + if indirect || params(&dict).iter().any(bad_predictor) { + return true; + } + let start = from + + usize::from(pdf.get(from) == Some(&b'\r')) + + usize::from(pdf.get(from + usize::from(pdf.get(from) == Some(&b'\r'))) == Some(&b'\n')); + let end = dict + .get::(b"Length") + .and_then(|n| start.checked_add(n)) + .filter(|e| *e <= pdf.len()) + .or_else(|| find(start, b"endstream")) + .unwrap_or(pdf.len()); + if matches!(decode(pdf.get(start..end).unwrap_or_default(), &dict, false), Err(TooBig)) { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dict(text: &str) -> Dict<'_> { + Dict::from_bytes(text.as_bytes()).unwrap() + } + + #[test] + fn predictors_the_reader_can_apply_pass() { + for ok in [ + "<< >>", + "<< /Predictor 1 /Columns 0 >>", + "<< /Predictor 12 /Columns 5 >>", + "<< /Predictor 2 /Colors 3 /BitsPerComponent 16 /Columns 1000 >>", + ] { + assert!(!bad_predictor(&dict(ok)), "{ok}"); + } + for bad in [ + "<< /Predictor 2 /Columns 0 >>", + "<< /Predictor 12 /Colors 0 >>", + "<< /Predictor 2 /BitsPerComponent 0 >>", + "<< /Predictor 2 /BitsPerComponent 3 >>", + "<< /Predictor 12 /Colors 32 /BitsPerComponent 16 >>", + "<< /Predictor 15 /Columns 99999999999 >>", + "<< /Predictor 10 /Columns 18446744073709551615 /Colors 4 >>", + ] { + assert!(bad_predictor(&dict(bad)), "{bad}"); + } + } + + #[test] + fn the_decoders_read_what_the_specification_writes() { + assert_eq!(hex(b"48 65 6c6C6\n>junk"), b"Hell`"); + assert_eq!(base85(b"87cURD]i,\"Ebo80~>"), b"Hello World!"); + assert_eq!(base85(b"z!!~>"), [0, 0, 0, 0, 0]); + assert_eq!(run_length(&[2, b'a', b'b', b'c', 254, b'x', 128, b'q']), b"abcxxx"); + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + std::io::Write::write_all(&mut e, &[7; 1000]).unwrap(); + assert_eq!(inflate(&e.finish().unwrap()).unwrap(), [7; 1000]); + // "-----A---B" in LZW, as the specification's example writes it (7.4.4.2). + let lzw_example = [0x80, 0x0B, 0x60, 0x50, 0x22, 0x0C, 0x0C, 0x85, 0x01]; + assert_eq!(lzw(&lzw_example, true).unwrap(), b"-----A---B"); + } + + /// Zeros past the cap, compressed. + fn zeros_past_the_cap() -> Vec { + let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + for _ in 0..(MAX_DECODED >> 20) + 1 { + std::io::Write::write_all(&mut e, &[0; 1 << 20]).unwrap(); + } + e.finish().unwrap() + } + + #[test] + fn a_stream_that_decodes_past_the_cap_is_found_without_holding_it() { + // Inflated in pieces: never more than one byte past the cap is held. + let bomb = zeros_past_the_cap(); + assert!(bomb.len() < MAX_DECODED / 500); + assert_eq!(inflate(&bomb).map(|d| d.len()), Some(MAX_DECODED + 1)); + let d = dict("<< /Filter /FlateDecode >>"); + assert!(matches!(decode(&bomb, &d, false), Err(TooBig))); + // Small enough that it can't get there: not decoded at all. + assert!(matches!(decode(&[1, 2, 3], &d, false), Ok(None))); + } + + #[test] + fn object_and_cross_reference_streams_are_looked_at_before_the_file_is_read() { + let stream = |dict: &str, data: &[u8]| { + [format!("1 0 obj\n<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream\nendobj\n"].concat() + }; + for bad in [ + stream("/Type /ObjStm /N 1 /First 4 /Filter /FlateDecode /DecodeParms << /Predictor 2 /Columns 0 >>", b"x"), + stream("/Type /XRef /Filter /FlateDecode /DecodeParms 9 0 R", b"x"), + stream("/Type /ObjStm /N 1 /First 4 /Filter /FlateDecode", &zeros_past_the_cap()), + ] { + assert!(unsafe_file_streams(&bad)); + } + for fine in [ + stream("/Type /XRef /Filter /FlateDecode /DecodeParms << /Predictor 12 /Columns 5 >>", b"x"), + // Another kind of stream is looked at once the file is open, its references followed. + stream("/Filter /FlateDecode /DecodeParms << /Predictor 2 /Columns 0 >>", b"x"), + ] { + assert!(!unsafe_file_streams(&fine)); + } + } +} From f1fd8e85e8664865086b6eb2f3e7827e34d0c236 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 10:13:26 +0700 Subject: [PATCH 26/37] PDF: krilla without its text shaping, so rustybuzz and ttf-parser don't ship (#1730) krilla's default `simple-text` feature is the only thing that brings in rustybuzz and ttf-parser, which RustSec lists as unmaintained. The PDF writer places glyphs itself, so the workspace takes krilla with `raster-images` only; the one test that writes files with `draw_text` turns `simple-text` on as a dev-dependency feature. --- Cargo.toml | 4 +++- crates/pdf/Cargo.toml | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2c3db2ed5..6915eb215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,7 +61,9 @@ wat = "1.228" vello_cpu = { version = "0.2", features = ["f32_pipeline"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } usvg = "0.48" -krilla = "0.8" +# Without `simple-text` (its text shaping, the only user of rustybuzz and ttf-parser, which are +# unmaintained, #1730): the PDF writer places glyphs itself. A test of the PDF crate turns it on. +krilla = { version = "0.8", default-features = false, features = ["raster-images"] } skrifa = "0.47" harfrust = "0.13" linesweeper = "0.5" diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index ea9e7c671..8629d12e1 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -39,3 +39,5 @@ vectorcraft-testkit = { workspace = true } # A variable font built in code (no font file is committed). vectorcraft-text = { workspace = true, features = ["test-fonts"] } serde_json = { workspace = true } +# `draw_text`, to write test files with live text as other writers do. +krilla = { workspace = true, features = ["simple-text"] } From f774968b2055520418208e43f26f82fedc70f050 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 09:41:07 +0700 Subject: [PATCH 27/37] xtask: the wasm check compiles the web app; the web's SVG depth cap follows from its stack `cargo xtask wasm` (part of `cargo xtask ci`) checked the wasm-safe crates but not apps/vectorcraft-web, so web-only code was never compiled before a merge. It now runs `cargo check --target wasm32-unknown-unknown -p vectorcraft-web` too (no Trunk, no network). The SVG importer's cap on the web was 48, sized from native measurements. rustc links the web build with a 1 MiB stack (`-z stack-size=1048576`; the app passes no linker arguments of its own) and usvg uses about 15 kB a level: half the stack at that cost gives 34, set in one place with the arithmetic. --- crates/svg/src/import/depth.rs | 18 ++++++++++++++++-- xtask/src/main.rs | 8 ++++---- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crates/svg/src/import/depth.rs b/crates/svg/src/import/depth.rs index 3febdd965..a172affe7 100644 --- a/crates/svg/src/import/depth.rs +++ b/crates/svg/src/import/depth.rs @@ -20,8 +20,22 @@ use super::css::{Styles, XNode, parse_decls}; /// that hold one object aren't kept, and what is still deeper is merged into the group around /// it). usvg uses about 15 kB of stack for each level: the importer runs on a thread with 16 MB /// ([`vectorcraft_doc::depth::with_deep_stack`]), except on the web, where there is the one -/// 1 MB stack. -pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { 48 } else { 256 }; +/// stack: [`WEB_DEPTH`] there. +pub const MAX_ELEMENT_DEPTH: usize = if cfg!(target_arch = "wasm32") { WEB_DEPTH } else { 256 }; + +/// The stack of the web build: rustc links `wasm32-unknown-unknown` with +/// `-z stack-size=1048576` (`rustc --print link-args` shows it), and the web app gives the linker +/// no arguments of its own (`apps/vectorcraft-web`: `Trunk.toml`, `index.html`, no rustflags). +const WEB_STACK: usize = 1 << 20; + +/// The stack usvg uses for each level of elements: about 70 levels overflowed the 1 MB main +/// thread of Windows. Measured natively, never in a browser. +const LEVEL_STACK: usize = 15 << 10; + +/// The deepest elements may nest on the web: half its stack at usvg's cost a level, the other +/// half left for what is on the stack when a file is opened and for the cost being a native +/// measure. 1,048,576 / (2 × 15,360) = 34. +const WEB_DEPTH: usize = WEB_STACK / (2 * LEVEL_STACK); /// Properties whose value an element's descendants use too: the paint server they name is /// converted at each descendant, under everything between. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 4030165e7..abcd42af5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -25,7 +25,7 @@ commands: assets check that every icon/image/font/asset is attributed in ASSETS.md brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products layers enforce the crate dependency layering (plan/architecture.md §3) - wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates + wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates and the web app ci fmt --check, clippy -D warnings, test, assets, brands, layers, wasm (stops at first failure) corpus [--download] [--affinity] show where test corpora live; --download fetches PngSuite into corpus/pngsuite, @@ -141,8 +141,8 @@ fn cmd_layers() -> Result<(), String> { } } -/// Workspace packages that must build for wasm32: all L0–L5 crates plus -/// the egui shell. +/// Workspace packages that must build for wasm32: all L0–L5 crates, the egui +/// shell, and the web app (its wasm-only code is compiled by nothing else). fn wasm_set() -> Result, String> { let crates = layers::from_metadata(&metadata()?)?; Ok(crates @@ -150,7 +150,7 @@ fn wasm_set() -> Result, String> { .filter(|c| match layers::classify(&c.name) { Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui", Some(layers::Class::Standalone) => true, - _ => false, + _ => layers::short_name(&c.name) == "web", }) .map(|c| c.name) .collect()) From 0c0d2448da4560f5b86e0ef7c54d6d92524c3dff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 11:22:40 +0700 Subject: [PATCH 28/37] =?UTF-8?q?Docs:=20ROADMAP=20and=20parity=20docs=20f?= =?UTF-8?q?or=20the=20later=202026-10-14=20merges=20(#1630=E2=80=93#1875);?= =?UTF-8?q?=20gaps=20noted=20for=20reporters;=20macOS=20save=20sheet=20For?= =?UTF-8?q?mat=20menu=20back=20to=20missing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ROADMAP.md | 5 ++++- docs/effects-parity.md | 3 ++- docs/file-format-parity.md | 3 ++- docs/gaps.md | 28 ++++++++++++++++++++++++++-- docs/roadmap.md | 3 ++- docs/target-app-parity.md | 10 +++++++++- docs/type-parity.md | 3 ++- docs/ui-parity.md | 10 +++++++--- 8 files changed, 54 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 199510d5b..800f8961c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,7 @@ **Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; macOS save sheets, #1855, the Format menu withdrawn; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; headline numbers unchanged; Image Trace: Shapes, stroke caps, enhanced presets, gradients over noise, #1845 to #1848, headline numbers unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; merges of 2026-10-14: anchor Align and Distribute, #1636; Direct Selection fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks and a Precise Cursors command, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; a guide's position in the Transform panel is a convenience, not a parity claim, #1659; headline numbers unchanged; later merges of 2026-10-13: SVG import of CSS transforms, percentage lengths in symbols, Inkscape flowed text and pages, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder Trim, Merge and Crop strokes, #1570; saves to folders that refuse the temporary file, #1620 in part; OpenType stylistic sets, #1607; intersect Smart Guides, #1602 in part; headline numbers unchanged; control transport deadlines, #1726; Image Trace edges, strokes, memory and three new options, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-13: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; `.ai`, PDF, EPS and SVG import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; headline numbers unchanged; community PRs and fixes of late 2026-10-12: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; French catalog complete again, #1511; headline numbers unchanged; Image Trace colour and photo presets, measured in `docs/image-trace-quality.md`, headline numbers unchanged; community PRs and fixes of 2026-10-12: Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; many-object edits, #1429 in part; headline numbers unchanged; Image Trace places edges to a fraction of a pixel, measured in `docs/image-trace-quality.md`, headline numbers unchanged; Transform dialogs preview and the transform tools act on direct-selected anchors, #1412 and #1408; `.ai` compound path paint, #1410, overprint, #1388, and text-only PDF parts, #1387; community PRs: strokes wider than their shape, corner drags without the bounding box, pasted pictures, compound paths in `document.inspect`, MCP guide; headline numbers unchanged; community PRs: `.ai` editing-data follow-ups (gradient stops, type of objects with an appearance, short area-type frames), PDF bleed with editing data, dialog keyboard focus, art flush with an artboard, font rescan, symbol instance redraw, `--control` port check; headline numbers unchanged; Rectangular Grid and Polar Grid tool options, #1377 in part; `.ai` files with placed (linked) pictures open from their editing data, #1264; community PRs: `.ai` and PDF bleed and editing-data import, gradient shapes imported as paths, ruler origin, Align To, Live Corners corner selection; headline numbers unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; community PRs: graph designs, Graph Data worksheet and Graph Type options; Align both centres; Pen endpoint fixes; earlier: Effect › Brush Strokes landed: Photoshop-style filters 28 of 57, raster effects 40% → 48%; `.ai` gradients, meshes and artboard names from the editing data; headline numbers unchanged; `.ai` art styles and clipping fills from the editing data; headline numbers unchanged; `editingData: "always"`; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x) VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON @@ -129,6 +129,8 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap | Date | What landed | |---|---| +| 2026-10-14 | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682). With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630). Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter). Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629). Redo during a drag keeps the drag as a step (#1856, #1719). Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter). Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809). The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735). macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855). | +| 2026-10-14 | Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767). Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744). Export for Screens and the Asset Export panel offer TIFF (#1632). SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870). DXF export writes the VPORT view direction once (#1789). Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695). Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717). Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683). A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694). krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875). | | 2026-10-14 | Image Trace: Shapes finds rounded rectangles and shapes in blurred and scanned art (98 to 100% of the plain-shape inputs, from 57 to 62%; #1845), square-ended lines get square caps (Outline's largest distance 2.94 px to 0.88; #1846), each documented preset has an enhanced version with Auto Grouping and Shapes that measures no worse than the preset (#1847), and blurred or noisy linear gradients trace as one gradient-filled path instead of 3 to 56 (#1848) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). No headline number moves. | | 2026-10-13 | Synthetic control input has finite click/drag counts and pending-event limits, checked before queue mutation; control-channel bursts yield after 64 requests per frame (#1709). | | 2026-10-14 | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing). Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674). In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666). Escape cancels a Direct Selection drag (#1803, #1743). Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656). A filled Pencil path keeps a stroke of None (#1804, part of #1679). With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663). The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661). In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Select › Same matches type on its characters' fill and stroke (#1746, #1737). Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). A drag whose last preview failed is no longer recorded as a committed command in the journal that actions and scripts read (#1775). Tests: text import fuzzing no longer searches installed fonts (#1785). Tests: render tests that add fonts no longer race the frame cache tests (#1795). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). Control replies are written through a 64 KiB buffer: a 10 MB `document.inspect` reply over loopback on Windows takes 0.25 s instead of 36 s (#1684). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The MCP server reconnects to the app before a call when the connection was closed or holds stray bytes, after an app restart or an idle expiry (#1739). Image Trace, measured in `image-trace-quality.md`: snap Curves To Lines moves no end of a line by more than 1.6 px, so long lines a degree or two off stay as the image has them (#1647: Technical Drawing's largest distance 4.21 px to 1.46); flat Logo takes the tones a scan's uneven paper makes of one colour for one colour (#1657: on scans 470 paths and 21 px off to 8.5 paths and 0.09 px). No headline number moves. | @@ -166,6 +168,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. | diff --git a/docs/effects-parity.md b/docs/effects-parity.md index a82372d82..7b367af95 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -99,6 +99,7 @@ Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Wide drop shadows and glows at high zoom render within the raster budget (#1706); stacked Transform effects are bounded (#1705); 3D effects keep the area a nonzero compound path fills (#1724); pattern tiles redraw when a symbol, pattern or image their art shows changes (#1723) (all #1864). Scores unchanged (estimated) | | 2026-10-14 | minor | Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648); scores unchanged (estimated) | | 2026-10-13 | minor | Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558); Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578); `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592); scores unchanged (estimated) | | 2026-10-13 | minor | 3D lighting takes the documented Intensity, Rotation, Height, Softness and Ambient Light (to 200 %), and a light behind the object; earlier lights read as the same ([three-d.md](three-d.md#lighting)); 3D stays ~25%, 25–45 h | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b009a3291..dac966233 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-10-14 · **Change:** minor (verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: SVG `:not()` selectors, #1557, stacked selectors, #1703, linked files, #1870; DXF VPORT, #1789; nesting caps, #1852; hostile PDF, `.ai`, EPS, Affinity and native data, #1862, #1863, #1871–#1873; failed saves, #1685, #1693, #1694; type in symbols after reopening, #1738; scores unchanged; verified PngSuite downloads, #1729; EPS stitched shadings, #1653; oversized images in WMF/EMF refused from the header, #1776; PDF name trees with shared nodes, #1781; native size limits for files read from memory, #1783; private temporary files on Unix, #1778; ruzstd 0.9.1, #1807; scores unchanged; SVG import: CSS transforms and the `font` shorthand, #1565, percentage lengths in symbols, #1596, Inkscape flowed text and pages, #1568, #1601, filter warnings, #1598; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment in JPEG, TIFF and PSD, #1584; saves to folders that refuse the temporary file, #1620 in part; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; compact native saves for documents with a lot of path data, #1492; PDF and EPS paste as vector art, #1499; `.ai` gradient stops listed upwards, #1530; SVG text on basic shapes, #1527; PDF soft masks drawn as images, #1551; Type 42 fonts in EPS, #1553; clips that only seem to cover the page, #1542; images too large to decode are reported, #1540; a `.ai` page's art past its artboard no longer lies over the page before it when read as its PDF part; grayscale TIFFs with an alpha channel or extra channels place and open, #1466 in part; a faded or masked fill keeps its stroke apart on PDF and `.ai` import, #1434; SVG knockout groups without masks, #1435; Affinity import: a text frame too short for its first line is made taller, #1423; `.ai` editing data: a compound path takes its last path's paint and keeps paths grouped inside it, #1410; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; `editingData: "always"` opens a `.ai` from its editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; `.ai` art styles, clipping fills and compound looks from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` gradient matrices, meshes and artboard names from the editing data; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; SVG knockout groups of opaque art without masks; a `.ai` whose PDF part is only text opens from it, #1387; PDF and `.ai` import keep overprint, #1388; `.ai` editing data: gradients with middle stops, #1390; type of objects with an appearance, hidden layers and later artboards, #1391; area type in a frame too short for the fallback font, #1392; `.ai` editing data: placed (linked) files read, #1264; gradient-filled shapes import as paths, #1304; white constant alpha soft masks, #1286 in part; DXF control-point SPLINE closure read, #1299 follow-up; `.ai` and PDF trim and bleed boxes as artboard and bleed, #1266; `.ai` keeps its RGB numbers; PDF import keeps a stroke apart from a fill masked or faded by its group) · **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/mod.rs`). Illustrator's list comes from its public @@ -90,6 +90,7 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | SVG import: `:not()` selectors (#1799, #1557), stacked descendant selectors over deep nesting no longer stall (#1834, #1703), linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); nesting capped at 32 objects with self-using symbols and patterns opened without the offending instance (#1852); deep and self-referencing PDF and `.ai` data (#1871), decompression bombs (#1873), self-drawing brushes (#1872), hostile EPS, Affinity and native data (#1863) and huge ids (#1862) handled; a failed save is reported in a dialog and the old file is kept (#1865, #1685, #1693, #1694); type inside symbols gets its layout bounds back on opening a native file (#1833, #1738). Scores unchanged (estimated) | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-14 | minor | EPS import: a stitched shading keeps its hard colour changes (#1654, #1653); WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774); Linked files that change during a drag are updated after the drag instead of being missed (#1773); scores unchanged | | 2026-10-13 | minor | SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); The warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); The pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); The ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); Saving to a network drive on macOS falls back to a plain fsync when the share refuses a full flush (#1625, part of #1620; not confirmed on a real share), and a save still succeeds when a folder refuses the temporary file, the rename over the document or copying permissions: the content is written elsewhere first and copied in, and a failed copy keeps the temporary file and names it (#1649, part of #1620); A document opened by a relative path finds its relatively linked images, and `vectorcraft-cli convert` lists what reading the input left out as `importWarnings` (#1606, fixes #1604); A zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613); scores unchanged (estimated) | diff --git a/docs/gaps.md b/docs/gaps.md index c157e658f..51121ac61 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14: G8 untrusted files, #1852, #1862–#1873; G11 anchor X/Y/W/H, #1824, Snap to Grid over Snap to Pixel, #1816, redo during a drag, #1719, Dim Images, #1683, failed-save dialog, #1685; gaps noted for reporters: G1 Warp Options for Effect › Warp, #1760, Color Picker Tab order, #1692, key anchor, #1636; G7 custom toolbars, #1759, workspace panel widths, #1753; G11 Shape Builder gaps between closed shapes, #1691, interior anchor delete; G13 Intertwine, #1763, gradient mesh from an image, #1676, Image Trace radial gradients, #1849; G12 duplicated line removed; no entry closed, estimates unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 fixes: G11 anchor Align and Distribute, #1636, Delete and selection, #1674, #1666, #1743, #1686, Roughen and Scribble, #1643, #1648; G8 file and input hardening, #1776–#1783, #1786, #1807; G10 Wayland cursors and the Windows title bar, unconfirmed, #1645, #1758; no entry closed, estimates unchanged; later 2026-10-13 fixes, #1570, #1578, #1590, #1613, #1615, #1616, #1620 in part, #1602 in part; no entry closed, estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; G12: Identify Objects By acts, #394; ~16 → ~15 prefs still inert; G12: Appearance of Black › Printing / Exporting acts, #394; ~16 → ~15 prefs still inert; control transport deadlines, #1726; G1: Average and Join in one step with a corner or smooth choice added as missing (it was listed as done in ui-parity.md); Join after Average, #1533; guide objects, #1534; Keyboard Shortcuts, #1539; G9 and G11 fixes, #1528, #1530, #1544, #1553, #1512, #1537; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; Tabs panel stop drags, #1458; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481, Twist range, #1524, twisted walls, #1525; print-size images, #1517; French catalog, #1511; estimates unchanged; G1: Split Into Grid options, #1462, Transform Objects and Reflect's angle, #1448 and #1460, Pathfinder on a group, #1450, value fields, #1457 and #1465 in part; G9: grayscale TIFFs with alpha, #1466 in part; G6: unknown parameter keys refused, #1442; G10: window surfaces past 8192 px, #1459 in part; G15: many-object edits, #1429 in part; estimates unchanged; G16: Vietnamese catalog added, complete under the tests; three key languages remain, 60–100 → 55–90 h; G3: Extrude & Bevel, Inflate and Rotate added, 45–75 → 25–45 h; G9: `.ai` compound path paint, #1410, overprint kept on import, #1388, text-only PDF parts, #1387; G1: Transform dialogs preview, #1412, transforms of direct-selected anchors, #1408, corner drags, #1229; G11: strokes wider than their shape, #1396; estimates unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` art styles from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` gradient matrices and meshes from the editing data; estimate unchanged; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; SVG knockout groups of opaque art export without masks; G9: `.ai` editing-data follow-ups (gradient stops, type placement, short area-type frames), #1390, #1391, #1392; G15: symbol instance art kept between frames, #1386; G1: dialog keyboard focus, #1367 and #1395; estimates unchanged; G9: placed (linked) files read from `.ai` editing data, #1264; G9: gradient shapes import as paths, #1304, and white constant alpha masks, #1286 in part; G15: linear deselect on hide or lock, #1353; estimates unchanged; G7: custom ruler origin done, #1212; G1: Artboard object clicks create a fitted artboard (#1227); remaining interaction pass and estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; G16: Korean catalog added, 70–110 → 60–100 h; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; G11: valid selection after Simplify and Add Anchor Points (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; G10: RPM runtime dependency metadata (#1275); estimates unchanged; G15: texture-effect distance searches optimized (#1257); idle-machine/platform drag QA still pending; estimate unchanged; G1: Curvature point display improved (#1236, in part); estimates unchanged; G4: full-name font matching (#1258); Windows font-service QA pending; estimate unchanged; G11: no-op anchor Align keeps history and saved state (#1180 follow-up); estimates unchanged; G1: dedicated Eyedropper cursor and precise-cursor checks (#1269); estimates unchanged; G1: Eyedropper reads visible locked and outside-isolation sources (#1251); estimate unchanged; G11: Direct Selection segment Delete (#1278); estimates unchanged; G1: painted strokes regain the existing default after None/zero-width inheritance (#1253); estimate unchanged; G15: large-selection edits, cached live effects and faster Pathfinder, #1173; G4: proportional vertical metrics done, #966; G11: Shape Builder merge seams on grids of lines fixed, #893; scaled text controls corrected, #1034; group font-size preservation deferred; G9: `.ai` keeps its RGB numbers, PDF import keeps strokes apart from fills faded or masked by their group; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Every known shortfall against Illustrator, one entry each, **ranked by how much it stops a professional from switching**. This is the work list: unless you were given a task, pick the highest-ranked gap you can make @@ -63,6 +63,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done (#1251):** Eyedropper source reads are independent of locks and isolation, including Shift/Shift+Alt and gradient stops. Source and selection preservation, unchanged Alt reverse/selection restrictions, hidden art and Undo/Redo have regression coverage. This addresses the reported behavior, with no new full-parity measurement. - **Done (#1253):** adding paint to an unpainted zero-width stroke restores the existing 1 pt default, including new-art defaults and Swap Fill/Stroke. The real Eyedropper inheritance sequence, targeted appearance items, preserved widths and Undo/Redo have regression coverage. - **Missing:** Average and Join in one step, with a corner or smooth choice for the joined anchor. `path.average` and `path.join` are separate commands and Join has no such choice (coinciding anchors merge into one since #1533). [ui-parity.md](ui-parity.md) listed "Join with corner/smooth choice" as done until 2026-10-13. About 1–2 h (estimated), inside the estimate below. +- **Missing (noted for reporters, 2026-10-14):** Effect › Warp edits through the Warp Options dialog (#1760); + Tab in the Color Picker moves through its fields in order (#1692); aligning anchor points selected with + Direct Selection to a key anchor (from #1636: they align to their bounds; the current public Align page does + not say how the key anchor is chosen, so this waits for the owner). Inside the estimate below. - **Missing:** a tool-by-tool, panel-by-panel pass over modifiers, cursors, the Properties panel per context, isolation mode and small behaviours, against the public tool and shortcut documentation. Itemized in [ui-parity.md](ui-parity.md). The Shaper's construction-mode widget and gesture refinements remain. @@ -105,6 +109,12 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** bounded synthetic control-input expansion (#1709): click/drag counts and pending-event capacity are checked before mutation, and control-channel bursts yield between frames. The corpus and platform QA below remain pending. - **Done (2026-10-14):** WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780). One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779). MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727). MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). The corpus and platform QA below remain pending. - **Done:** control sockets have bounded idle, absolute partial-line and reply-write waits (#1726); real TCP tests cover idle, trickled and blocked peers, buffered partial lines, persistent requests and connection-slot recovery. The corpus and platform QA below remain pending. +- **Done (later 2026-10-14):** nesting capped at 32 objects across SVG, PDF, EPS and native files, self-using symbols + and patterns (#1852); deep or self-referencing PDF and `.ai` data (#1871), decompression bombs and impossible + predictors (#1873, #1700, #1701), self-drawing brushes (#1872), hostile EPS, Affinity and native data (#1863: + #1715, #1714, #1704, #1699; save fallback #1695), ids at their largest value, failed `command.batch` rollback, + timed-out control requests and oversized Eyedropper images (#1862: #1718, #1697, #1725, #1717), bounded shadows, + glows and stacked Transform effects (#1864: #1706, #1705). - **Missing:** a corpus of real-world SVG/PDF/EPS files; more Affinity versions and platforms (51 pinned files now, including 28 original Affinity 3.2.3 feature probes; large interacting documents and complex typography missing); Windows, Linux and browser QA by hand; packaging polish (Homebrew cask #588, Flathub #554, update @@ -129,6 +139,15 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall scaled text's size and horizontal-scale controls account for object/group transforms (#1034), including exact sizes across grouped labels and resetting horizontal stretch while keeping editable text. Single-result Pathfinder booleans keep source names and SVG IDs, including named groups/compound paths (#1293). +- **Done (later 2026-10-14):** the Transform panel's, Properties panel's and Control bar's X/Y/W/H edit the anchor points + selected with Direct Selection (#1839, #1824); Snap to Grid wins over Snap to Pixel (#1840, #1816 unconfirmed); Redo + during a drag keeps the drag (#1856, #1719); a template layer dims its images by Dim Images instead of fading the + whole layer (#1864, #1683); a failed save or export is reported in a dialog that names the file and stays until + closed, the document left modified (#1865, #1685). +- **Missing (noted for reporters, 2026-10-14):** the Shape Builder offers no region for an empty area enclosed + only by closed shapes, such as the middle of four circles that touch (#1691); deleting an interior anchor of an + open path joins across the gap, where the documented behaviour removes the point and its connecting segments. + Inside the estimate below. - **Missing:** Shape Builder misses planar regions and targets whole shapes (#937); Transform effect dialog's invisible checkboxes and missing options (#885); PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). - **Impact:** wrong output in everyday work. @@ -151,7 +170,6 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G12 Preferences and panels that don't act yet - **Missing:** ~15 Preferences options stored but not acted on (#394: Highlight Alternate Glyphs, Open - Documents As Tabs, Real-time Drawing, hyphenation language, Appearance of Black for output, devices…), Properties panel per Documents As Tabs, Real-time Drawing, hyphenation language, devices…), Properties panel per context, context menus beyond the canvas, Layers panel row menus and Collect for Export, SVG Interactivity. - **Impact:** settings that silently do nothing erode trust. @@ -170,6 +188,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall - **Done:** TIFF choices and format settings in Export for Screens and Asset Export (30.2), using the existing encoder; UI-to-file regressions cover selection, scaling, options and persistence. +- **Missing (asked for by users, from earlier versions):** Object › Intertwine (#1763); Object › Create Gradient + Mesh on an embedded image, which turns it into a mesh of its colours (#1676); radial gradients in Image Trace's + Gradients option, which traces linear ones only (draft #1849). Inside the estimate below. - **Missing:** gradient dither and perceptual blending, gradient presets (30.0); tangent and perpendicular snapping and the consolidated snapping options (30.0); artboard locking, artboard colours and managing artboards from the canvas (30.0); the Blend panel with ease in/out and separate colour acceleration (30.7); @@ -204,6 +225,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall ## G7 Views and windows - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen. +- **Missing (noted for reporters, 2026-10-14):** custom toolbars (Window › Toolbars › New Toolbar, #1759); + panel widths kept with a workspace (#1753). Inside the estimate below. - **Missing:** multiple windows and Window › Arrange (Cascade, Tile, Float in Window, Consolidate All Windows), panels on another monitor, New View/Edit Views, global/artboard ruler mode switch and video rulers. - **Estimate:** 15–25 h. @@ -262,6 +285,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | No entry closed. G8 and G11 record the later merges of 2026-10-14 (#1852, #1862–#1865, #1871–#1873, #1839, #1840, #1856); #1683 (Dim Images) and #1685 (failed-save dialog) are done by #1864 and #1865. Gaps noted for reporters: G1 Effect › Warp through Warp Options (#1760), Color Picker Tab order (#1692), aligning to a key anchor (#1636; the public Align page does not say how the key anchor is chosen); G7 custom toolbars (#1759), workspace panel widths (#1753); G11 Shape Builder regions between closed shapes (#1691), deleting an interior anchor of an open path; G13 Intertwine (#1763), gradient mesh from an embedded image (#1676), Image Trace radial gradients (draft #1849). Average and Join with a corner or smooth choice was already listed (G1). G12: a line two merges had left twice is there once, without Appearance of Black for output, which acts since #1750. Estimates unchanged | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | | 2026-10-14 | minor | No entry closed. G11: Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643); Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). G8: WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776); PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781); The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783); Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778); An Export for Screens suffix can no longer place files outside the export folder (#1777); Package refuses a folder where it would overwrite the open document or one of its linked files (#1786); ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807); Scatter graph data near the largest number is refused with a message instead of hanging the app (#1780); One very long Liquify stroke segment can no longer create an unbounded number of dabs (#1779); MCP limits: 16 MiB lines, 64-message batches, 256-step `command_batch` with 8 MiB of kept results and 256 MiB control replies (#1764, fixes #1727); MCP requires the JSON-RPC 2.0 envelope and matches control replies by id, and an app refusal without an id reports the app's reason (#1768, fixes #1728). G10, unconfirmed: Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). A clause repeated in two G11 bullets is there once. Estimates unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 161359f43..f2b13b719 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: per-corner radius, #1682; Width Point Edit from the Width tool, #1814, #1630; guides, #1782, #1836; anchor X/Y/W/H, #1824; toolbar and All Tools, #1751; macOS save sheets, #1855; rendering caches, #1667–#1792; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; Dim Images, #1683; estimates unchanged; M12 row: Image Trace's enhanced presets; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Expand on repeats, #1756; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace #1647, #1657; estimates unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1601; Warp Arc, #1578; Pathfinder strokes, #1570; saves, #1620 in part; OpenType features, #1607; intersect Smart Guides, #1602 in part; estimates unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; M12 row: Image Trace's presets and options as they are now; Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts, #1539; estimates unchanged; Direct Selection anchor precedence, #1508; dialog slider rails, #1483; template layers in Outline view, #1486; type that does not fit its frame is reported, #1471; 3D on outlined type, #1481; print-size images, #1517; French catalog, #1511; estimates unchanged; Split Into Grid options, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; estimates unchanged; M9: Extrude & Bevel, Inflate and Rotate; `.ai` compound path paint, #1410, overprint on import, #1388, text-only PDF parts, #1387; Transform dialogs preview, #1412, and transforms of direct-selected anchors, #1408; strokes wider than their shape, #1396; estimates unchanged; `.ai` editing-data follow-ups, #1390, #1391, #1392; dialog keyboard focus, #1367 and #1395; symbol instance redraw cache, #1386; font rescan, #1376; estimates unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click, #1239; custom ruler origin, #1212; M11: Artboard object clicks create a fitted artboard (#1227); estimate unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; M6: Simplify preview/counts and valid topology selection (#1272); estimates unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; M14: RPM runtime metadata (#1275); estimates unchanged; M5: squared-distance texture redraws (#1257); clean-machine/platform drag measurements still pending; estimates unchanged; M2: Curvature point display improved (#1236, in part); estimates unchanged; M7: full-name font matching (#1258); Windows font-service QA pending; estimates unchanged; M1: no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); estimates unchanged; M14: dedicated Eyedropper cursor (#1269); estimates unchanged; M3: Eyedropper reads visible locked and outside-isolation sources (#1251); estimates unchanged; M2: Direct Selection segment picks and Delete (#1278); estimates unchanged; M3: adding paint to an unpainted zero-width stroke restores 1 pt (#1253); estimates unchanged; M12: graph designs, graph data, Graph Type and label options; M14: native macOS save Format menu, #866; M6: compound appearance and member fill-rule fixes, #1197; estimates unchanged; M9: PDF raster-effect cap warnings; G11: #893 fixed, #937 open; M7: accurate character controls after scaling, #1034; estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) Forward-looking plan. Where VectorCraft stands is in [`ROADMAP.md`](../ROADMAP.md) and [target-app-parity.md](target-app-parity.md); the ranked work list is [gaps.md](gaps.md). Hours are Opus 5.5 @@ -80,6 +80,7 @@ After placed documents (File › Place of a `.vectorcraft` file, linked), M4.14 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Corner Radius has a link button in Properties and Transform: unlinked, a live rectangle shows a radius field for each corner (#1682); With the Width tool, double-clicking a stroke opens Width Point Edit to create a width point there, and Shift-double-click on a width point opens it with Adjust Adjoining Width Points ticked (#1814, #1630); Holding Alt while dragging a guide out of a ruler switches it between horizontal and vertical (#1782), and a marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836); The Transform panel's, Properties panel's and Control bar's X/Y/W/H show and edit only the anchor points selected with Direct Selection (#1839, fixes #1824); With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1840; #1816 waits for the reporter); Artboards are deleted the same way from the Artboards panel, its menu, Properties and Delete/Backspace (#1631); The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (#1751); An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629); Redo during a drag keeps the drag as a step (#1856, #1719); Preferences are written as soon as OK is pressed (#1857; #1747 waits for the reporter); Preferences › Appearance of Black › Printing / Exporting and Units › Identify Objects By act (#1750, #1809); The paragraph alignment buttons show none selected for paragraphs with different alignments (#1784); redefining a character style to Auto leading updates the text that uses it and keeps local overrides (#1633); New and Redefine Character Style take the format of the selected text range, also while typing (#1634); Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738); Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks (#1830, #1735); macOS: Save As, Save a Copy and the first Save show their sheet again; the sheet's Format menu (#866, reopened) is withdrawn until it can be set before the sheet opens (#1855); Rendering: copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667); the canvas no longer reads its whole picture back for each non-isolated group that blends (#1687); envelopes, blends, repeats and meshes redraw when a pattern, symbol, font or colour setting they show changes (#1688); Outline view draws from geometry kept between frames (#1689); CMYK documents with transparency compose their ink planes on several threads (#1755); an edit re-flows a threaded story only when it changes one of the story's frames (#1792); opening a file reads its linked files and lays out its type on the opening thread (#1767); Instances of imported symbols are measured, selected, aligned, culled and exported by their art instead of a 20 pt square (#1744); Export for Screens and the Asset Export panel offer TIFF (#1632); SVG import looks style rules up by id, class and type (#1690), applies `:not()` selectors (#1799, #1557), no longer stalls on stacked descendant selectors over deep nesting (#1834, #1703) and reads linked files within the automation roots again (#1870); DXF export writes the VPORT view direction once (#1789); Untrusted files: nesting is capped at 32 objects in SVG, PDF, EPS and native files and symbols or patterns that use themselves open without the offending instance or paint (#1852); deeply nested PDF and `.ai` data, page trees holding themselves and functions or colour spaces built on themselves (#1871), decompression bombs and impossible predictor parameters (#1873, #1700, #1701), brushes that draw themselves (#1872) and hostile EPS, Affinity and native data (#1863: #1715, #1714, #1704, #1699) no longer crash or exhaust the app; the save fallback keeps the old file until the new one is in (#1695); Files whose ids sit at their largest value, a failed `command.batch`, control requests that timed out and images too large as RGBA for the Eyedropper are handled (#1862: #1718, #1697, #1725, #1717); Effects: wide shadows and glows at high zoom stay within the raster budget, stacked Transform effects are bounded, 3D keeps the area a nonzero compound path fills, pattern tiles redraw when the art they show changes, and a template layer dims its images by Dim Images (#1864: #1706, #1705, #1724, #1723, #1683); A failed save or export is reported in a dialog that names the file and stays until closed (#1865, #1685); a browser download that can't start fails the save (#1693); a file that finishes opening waits while a dialog is open (#1694); krilla's text shaping is no longer built in (#1874, #1730); the CI gate compiles the web app for wasm (#1875); milestone rows and estimates unchanged | | 2026-10-14 | minor | M12 row: each documented Image Trace preset has an enhanced version (#1847) | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index be8a18010..04ef099a7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: anchor X/Y/W/H, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar and All Tools, #1751; Actions panel selection, #1629 in part; redo during a drag, #1719; preferences written on OK, #1747; type in symbols after reopening, #1738; macOS save sheets, #1855, the Format menu withdrawn; stroke outlines shared, #1667; untrusted files, #1852, #1862–#1873; failed-save dialog, #1685; effects, #1864, Dim Images, #1683; duplicated rows of the 2026-10-05 table noted for the owner; scores unchanged; Image Trace: Shapes rework, stroke caps, enhanced presets, gradients over noise, measured in `image-trace-quality.md`, score unchanged; verified PngSuite downloads, #1729; synthetic control-input budgets, #1709; 2026-10-14 merges: anchor Align and Distribute, #1636; Direct Selection and Delete fixes, #1674, #1666, #1743; Escape while typing, #1686; Pen clicks, #1663; Expand on repeats, #1756; Roughen and Scribble, #1643, #1648; file and input hardening, #1776–#1783, #1786, #1807; MCP limits and envelope, #1727, #1728; Image Trace levelling and Flat Logo on scans, #1647, #1657; scores unchanged; later 2026-10-13 merges: SVG import, #1565, #1596, #1568, #1598, #1601; type on a path from PDF and `.ai`, #1585, #1586; ICC tag alignment, #1584; saves, #1620 in part; Warp Arc, #1578; Zig Zag, #1558; Pathfinder strokes, #1570; OpenType features, #1607; intersect Smart Guides, #1602 in part; a guide's position in the Transform panel, #1597 in part, a convenience with no parity claim; scores unchanged; TIFF is available in Export for Screens and Asset Export with shared format settings; scores and hours unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; Units › Identify Objects By (`identifyObjectsBy`) acts on Layers, Variables and Layer Options, #394, scores unchanged; Appearance of Black › Printing / Exporting (`blackOutput`) acts on RGB raster export and Print as Bitmap, #394, scores unchanged; per-corner radius fields with a link toggle, split from #1594; control transport deadlines, #1726; Color panel: the mode picked last comes back after a restart, #1675; Image Trace: shared edges, memory, thin lines, strokes, corners, and the Shapes, Auto Grouping and Gradients options, measured in `image-trace-quality.md`, score unchanged; 2026-10-13 merges: Polish interface, #1498; compact native saves, #1492; PDF and EPS paste as vector art, #1499; import fixes for `.ai`, PDF, EPS and SVG, #1528, #1530, #1531, #1544, #1551, #1553; guide objects, #1534; Join after Average, #1533; Keyboard Shortcuts lists commands outside the menus, #1539; correction: Average and Join with a corner or smooth choice is missing; scores unchanged; late 2026-10-12 merges: type that does not fit its frame is reported, #1471; Tabs panel drags, #1458; template layers in Outline view, #1486; dialog slider rails, #1483; Direct Selection anchor precedence, #1508; print-size images, #1517; 3D on outlined type, #1481; French catalog, #1511; scores unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; Localization: Vietnamese catalog added, 63% → 71%, hours 60–100 → 55–90, full readiness unchanged at ~62%; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks; Image Trace: colour presets no longer shatter noisy flat art and photos trace into smooth tones, measured in `image-trace-quality.md`, score unchanged; Split Into Grid options, #1462; Transform Objects and Reflect across an angle, #1448 and #1460; Pathfinder panel on a group, #1450; unknown parameter keys refused, #1442; grayscale TIFFs with alpha, #1466 in part; scores and hours unchanged; Image Trace places edges to a fraction of a pixel in every mode, measured in `image-trace-quality.md`, score unchanged; 3D and Materials: Extrude & Bevel, Inflate and Rotate added, 3D ~8% → ~25% (estimated), menu stubs 25 → 22; Affinity text frames too short for their first line show it, #1423; Transform dialogs preview, #1412, and the transform tools and commands act on direct-selected anchors, #1408; `.ai` compound path paint, #1410; strokes wider than their shape, #1396; corner drags hide the bounding box, #1229; scores and hours unchanged; PDF and `.ai` import keep overprint, #1388, scores unchanged; `.ai` editing-data follow-ups: gradient stops, type of objects with an appearance, short area-type frames (#1390, #1391, #1392); dialog keyboard focus on every open (#1382, #1395); art flush with an artboard (#1375); font rescan (#1376); symbol instance redraw cache (#1386); scores and hours unchanged; grid tool options, #1377 in part, scores unchanged; `.ai` placed (linked) files read from the editing data, #1264, scores unchanged; gradient shapes import as paths, #1304; white constant alpha masks, #1286 in part; Live Corners widget click selects its corner, #1239; scores and hours unchanged; custom ruler origin, #1212; Artboard click-to-fit verified for objects, groups, visible bounds, undo and cancellation (#1227); scores and hours unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; Localization: Korean catalog added, 54% → 63%, full readiness unchanged at ~62%; desktop print transport adopts craft-print, Windows 7 fallback retained, scores unchanged; Image Trace: an image over 64 megapixels traces scaled down instead of being refused, scores unchanged; Czech render-site localization follow-up; scores and estimates unchanged; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Path operations: Simplify preview/counts and component selection (#1272); scores unchanged; G2: Film Grain added; Photoshop effects 29/57; 28 filters remain; Platforms: RPM runtime capabilities and glibc floor (#1275); scores unchanged; Raster effects: squared-distance texture redraws (#1257); pixels unchanged; scores and hours unchanged; Drawing: Curvature point display improved (#1236, in part); scores unchanged; Type core: full-name font matching (#1258), scores and hours unchanged; Selection: no-op anchor Align preserves history and saved state (#1180 follow-up); scores unchanged; UI chrome: Eyedropper cursor and precise-setting regressions (#1269); scores unchanged; Colour: Eyedropper reads visible locked and outside-isolation sources (#1251); scores and hours unchanged; Selection: segment click/Delete preserves neighboring edges (#1278); scores unchanged; Colour: unpainted zero-width strokes regain 1 pt when painted (#1253); scores and hours unchanged; graphs: designs and markers, data, Graph Type and label options; UI chrome: native macOS save Format menu, #866, scores unchanged; compound paths inherit the backmost appearance and member fill-rule edits reach their owner, #1197; scores unchanged; Type core: Middle Eastern digit types done, Character panel menu › Digits; PDF export warns when the pixel cap lowers raster-effect resolution; Drawing tools: Live Corners round corners between curved sides, #1144; Type advanced: proportional vertical metrics (`vpal`) done, #966; Shape Builder: #893 fixed, merged regions of crossing lines no longer keep seams; scores unchanged while #937 is open; #1034: document-point character controls after object/group scaling; full readiness recomputed as the standard's additive weighted sum, ~62%; per-audience hours; essentials-user score ~75% added; ready-for-real-work re-examined: 50% → 55%, mainstream 65% added; earlier the same day: full re-measure against Illustrator 2026 30.x; merged the ROADMAP's parity estimate, honest assessment and "Shipped so far" into this file; SVG knockout groups of opaque art export without masks) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) The authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the work list made from it. Deep areas have their own checklists: [UI and interaction](ui-parity.md), @@ -291,6 +291,10 @@ The full feature/readiness headline remains unchanged at its existing rounding. ## Detailed missing items per area (2026-10-05 table, still current unless the area table above says otherwise) +> **Duplicated rows (2026-10-14):** this table lists "Selection, transform & align tools" three times and "Drawing tools" +> twice, each time with different text, as merges that each edited the table left them. Which text is current needs the +> owner's judgement, so the rows are kept as they are. + The previous estimate's per-area missing lists, kept as evidence. Scores in it are superseded by the table above. _Method (2026-10-02; M3 rows re-derived 2026-10-04, file, export, print, links and UI-chrome rows after M4 on 2026-10-05):_ Illustrator's feature surface is split into 22 areas, weighted by how much of the app (and of @@ -530,6 +534,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Import and export, later 2026-10-13 merges: SVGs that move objects with a CSS transform (`translate(100px, 0)`, `rotate(45deg)`) or style type with the `font` shorthand import as drawn (#1565); SVG symbols whose shapes use percentage lengths import at the size and place they are drawn, the percentages being of the symbol's `viewBox` (#1596); Inkscape's flowed text (`flowRoot`) opens as area type in its rectangle, a paragraph per `flowPara`, instead of being dropped, and a region that can't be read is left out with a warning (#1569, fixes #1568); the warning for an unsupported SVG filter names the filter, its primitives and the object, and says the object is drawn without it (#1599, fixes #1598); the pages of an Inkscape multi-page SVG become artboards, named by their labels (#1603, fixes #1601); SVG export with CSS classes or style entities stays fast in documents with many distinct styles (#1567); PDF and `.ai` import: evenly letter-spaced or tightened type on a path keeps its length along the path instead of closing up to the font's advances (#1586), and type on a path whose stroke is written glyph by glyph comes in as one text object with its stroke, not one object per glyph (#1585); the ICC profiles embedded in JPEG, TIFF and PSD exports have every tag on a 4-byte boundary (ICC.1 §7.1.2), so strict readers accept them (#1589, fixes #1584); GIF, PNG-8 and other indexed exports find each pixel's palette colour through an index instead of a scan, with identical output (#1576); a zero-length subpath draws its round cap as a dot and its projecting cap as a square, so dots in imported `.ai` and SVG files show (#1619, fixes #1613). - Files and untrusted input, 2026-10-14 merges: EPS import: a stitched shading keeps its hard colour changes (#1654, #1653). WMF/EMF import refuses an oversized embedded PNG or JPEG from its header, before any pixels are decoded (#1776). PDF open no longer takes exponential time on a crafted name tree with shared nodes when it looks for the editing data (#1781). The size limits on a native file's embedded data also apply to files read from memory, not only streamed ones (#1783). Saving over a private file on Unix no longer leaves a readable temporary copy beside it while the file is written (#1778). An Export for Screens suffix can no longer place files outside the export folder (#1777). Package refuses a folder where it would overwrite the open document or one of its linked files (#1786). ruzstd 0.9.1 fixes a decompression bomb (RUSTSEC-2026-0344) reachable from EPS/`.ai` and Affinity import (#1807). File Info dates are stamped only after a save succeeds, so a failed save no longer changes them (#1774). Linked files that change during a drag are updated after the drag instead of being missed (#1773). - **Fields, search and window, 2026-10-14 merges:** Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787). Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757). Slider rails show on Medium Dark (#1808, #1662). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation). +- **Tools, panels and type, later 2026-10-14 merges:** the Transform panel's, Properties panel's and Control bar's X, Y, W and H show and edit the bounds of the anchor points selected with Direct Selection, and a whole-object selection is unchanged (#1839, fixes #1824). With Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked: the grid wins (#1840; #1816 stays open until the reporter confirms). A marquee with the Selection or Direct Selection tool also selects the unlocked ruler guides it crosses (#1836). The toolbar shows no group labels by default (a preference brings them back) and runs on in one or two columns; the All Tools drawer lists the tools by flyout group with their shortcuts, has a menu (Reset, Basic, Advanced) and a Show section for the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls (`toolbar.show`, #1751). An action clicked in the Actions panel stays selected, so Play Current Selection and Delete Selection act on it (#1853, part of #1629; editing actions and action shortcuts remain). Redo pressed during a drag keeps the drag as a step instead of losing the redone edit (#1856, #1719). Preferences are written as soon as OK is pressed, so they survive a session that doesn't end with a normal quit (#1857; #1747 stays open until the reporter confirms). New and Redefine Character Style take the format of the selected text range, also while typing (#1634). Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back when a native file opens, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Editing, renaming, deleting or merging a global swatch reaches the art of opacity masks, and a swatch used only in a mask is no longer listed as unused (#1830, #1735). On macOS, Save As, Save a Copy and the first Save show their sheet again; the Format menu on the sheet (#866) is withdrawn until the format can be set before the sheet opens, and #866 is open again (#1855; #1800, #1831 and #1518 stay open until confirmed on a Mac). +- **Files and untrusted input, later 2026-10-14 merges:** SVG import applies style sheet rules with `:not()` selectors, with their nesting bounded (#1799, #1557), no longer stalls on a style sheet with many stacked descendant selectors over deep nesting (#1834, #1703), and reads an SVG's linked files within the automation roots in force again (#1870). DXF export writes the VPORT record's view direction once (group code 36 was written twice, which strict readers reject, #1789). A deeply nested file no longer overflows the stack: nesting is capped at 32 objects (deeper groups are merged with a warning; SVG past 256 elements and PDF past 256 nested arrays are refused), symbols and patterns that use themselves open without the offending instance or paint, and a document nested more than about 40 deep can no longer be saved to a file that won't open again (#1852). PDF and `.ai` files nested deeply inside compressed streams, page trees holding themselves and functions or colour spaces built on themselves no longer crash the app (hayro 0.8; the import runs on a 16 MB thread, #1871); a decompression bomb or impossible predictor parameters are refused (#1873, #1700, #1701); a brush whose art draws the brush again through a symbol or pattern no longer crashes the app, and graph designs, Affinity, DXF, metafiles and plug-ins were probed with hostile input (#1872). Hostile EPS, Affinity and native files can no longer run memory or CPU away through ASCII85 data, PostScript objects holding themselves, flat-colour pixel layers or far and overlapping data offsets (#1863: #1715, #1714, #1704, #1699), and the save fallback for folders that refuse a rename keeps the old file's content until the new one is in (#1695). A file whose ids sit at their largest value no longer breaks new objects (#1718); a failed `command.batch` puts each document back whole, its file, history and a drag in progress included (#1697); a control request that timed out before the app took it never runs later, and the reply says which case it was (#1725, [control-protocol.md](control-protocol.md)); the Eyedropper and recolouring refuse an image too large as RGBA before decoding it (#1717) (all #1862). A save or export that fails is reported in a dialog that names the file and stays until closed, with the document left modified and the old file untouched (#1685); a browser download that can't start fails the save instead of marking it saved (#1693); a file that finishes opening waits while a dialog is open, so Save As options can't save the wrong document (#1694) (all #1865). krilla is built without its text shaping, so the unmaintained rustybuzz and ttf-parser don't ship (#1874, #1730); the CI gate compiles the web app for wasm, and the web's SVG nesting cap (34) follows from its 1 MiB stack (#1875). +- **Rendering, later 2026-10-14 merges:** copies, symbol instances and repeats that share an appearance keep their stroke outlines between frames (#1667). Wide drop shadows and glows at high zoom render within the raster budget (#1706); stacked Transform effects are bounded (#1705); 3D effects keep the area a nonzero compound path fills (#1724); pattern tiles redraw when a symbol, pattern or image their art shows changes (#1723); a template layer dims its images by its Dim Images percentage instead of fading the whole layer (#1683) (all #1864). - **Tool cursors:** the Eyedropper has an original pipette glyph with its tip at the hotspot; Use Precise Cursors restores the crosshair. The same glyph feeds OS bitmaps and painted cursors. DPI/hotspot and preference checks in `cursors.rs` and `tests_prefs.rs` cover #1269; @@ -569,6 +576,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Evidence for the later merges of 2026-10-14 that did not write their own: anchor X/Y/W/H (#1839), Snap to Grid over Snap to Pixel (#1840), guides in a marquee (#1836), the toolbar and All Tools drawer (#1751), the Actions panel selection (#1853), redo during a drag (#1856), preferences written on OK (#1857), character styles from a selected range (#1634), type in symbols after reopening (#1833), swatches in opacity masks (#1830), macOS save sheets (#1855, the Format menu withdrawn), shared stroke outlines (#1667), SVG and DXF fixes (#1799, #1834, #1870, #1789), untrusted files (#1852, #1862, #1863, #1871–#1873), failed saves (#1865), effects and Dim Images (#1864), krilla (#1874) and the wasm gate (#1875). #1630, #1631, #1632, #1633, #1682, #1687–#1690, #1744, #1750, #1755, #1767, #1782, #1784, #1792, #1809, #1814 and #1850 wrote their own evidence and are not repeated. A note above the 2026-10-05 table says its duplicated "Selection, transform & align tools" and "Drawing tools" rows need the owner's judgement. Scores and hours unchanged (estimated) | | 2026-10-14 | minor | Image Trace, measured in `image-trace-quality.md`: Shapes rework (#1845), square stroke caps (#1846), enhanced presets (#1847), gradients grown over noise and soft rims (#1848). Score and hours unchanged (estimated) | | 2026-10-13 | minor | Pinned PngSuite HTTPS files verified by SHA-256 before publication; bounded downloads replace archive extraction (#1729). Scores and hours unchanged. | | 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index 717cd3991..b1c57d910 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -58,6 +58,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Score unchanged (estimated) | | 2026-10-14 | minor | Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); Select › Same matches type on its characters' fill and stroke (#1746, #1737); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); scores and hours unchanged | | 2026-10-13 | minor | Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607); scores and hours unchanged | | 2026-10-13 | minor | Paragraph/Properties alignment buttons show mixed alignment without changing range commands; headless regressions, full integration CI and visual review pass; other mixed paragraph fields, scores and hours unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7f9ed3a15..ea5c3f192 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (later 2026-10-14 merges: macOS save sheet Format menu back to missing, #1855, #866 reopened; anchor X/Y/W/H in the Transform panel, Properties and Control bar, #1824; Snap to Grid over Snap to Pixel, #1816; toolbar labels, columns and the All Tools drawer, #1751; Actions panel selection, #1629 in part; scores unchanged; 2026-10-14 merges: anchor Align and Distribute, #1636; Delete on closed paths and whole objects, #1674; selection inside isolation, #1666; Escape in a Direct Selection drag and while typing, #1743, #1686; Pen clicks under 3 px, #1663; [ and ] on more tools, #1661 in part; fields commit on click away, #1787; command search keys, #1757; a guide's position in the Transform panel is a convenience with no parity claim, #1659; scores unchanged; intersect Smart Guides for drawn points and dragged anchors, #1602 in part; a ruler guide's position in the Transform panel, #1597 in part; Symbol Options before New Symbol, #1560; tool flyout widths, #1616; Pathfinder Trim, Merge and Crop strokes, #1570; scores unchanged; G1/M11: Artboard deletion and history selection normalization; history regressions and full workspace CI pass; scores and hours unchanged; Width Point Edit: Shift-double-click enables adjoining adjustment; plain double-click keeps it off; scores and hours unchanged; per-corner radius fields with a link toggle, split from #1594; Join merges anchors lying on one another, #1533; correction: Average and Join with a corner or smooth choice is missing, not done; guide objects take the guide colour and style and Lock Guides, #1534; Keyboard Shortcuts lists commands outside the menus, #1539; round corners next to curves and the Smooth tool's reach, #1512; Make Compound Path on a group, #1537; dialog slider rails, #1483; template layers stay in Preview in Outline view, #1486; Direct Selection takes the selected object's anchor where anchors coincide, #1508; Tabs panel stop drags, #1458; Split Into Grid dialog, #1462; Transform Objects and Reflect's angle, #1448 and #1460; Pathfinder panel on a group, #1450; value fields: readable selection, #1465 in part, and a focused field follows its value, #1457; scores unchanged; Transform dialogs have a live Preview and step with the arrow keys, #1412; transform tools, dialogs and Move act on direct-selected anchors, #1408; the bounding box hides during a corner drag, #1229; scores unchanged; dialogs focus their first field on every open, #1367, and a first field that starts greyed out once it is enabled, #1395; scores unchanged; Rectangular Grid and Polar Grid click dialogs take the skew, frame, compound path and Fill Grid options, #1377 in part, score unchanged; Live Corners: a Direct Selection click on a widget selects its corner, #1239; custom ruler origin, #1212; Artboard tool clicks fit the topmost editable object in one undo step (#1227); scores unchanged; Pathfinder single-result source names and SVG IDs retained, #1293; DXF control-point SPLINE closure read, #1299 follow-up; Document Setup: Edit Artboards closes the modal after applying valid settings; pointer-click regressions; scores and hours unchanged; Artboards: Escape exits editing; an active drag cancels first; shortcut-routing regression; scores and hours unchanged; Simplify preview/counts and valid rewritten-path selection (#1272); parity remains partial; Texture-effect drag redraws optimized (#1257); idle Windows interaction still unverified; scores unchanged; Curvature point display improved (#1236, in part); scores unchanged; Direct Selection: no-op anchor Align preserves Undo, Redo and saved state (#1180 follow-up); Eyedropper cursor distinguished from precise crosshair (#1269); scores unchanged; Eyedropper can read visible locked and outside-isolation sources (#1251); scores unchanged; Direct Selection: independent segment picks and Delete (#1278); scores unchanged; Adding paint to an unpainted zero-width stroke restores the 1 pt default (#1253); scores unchanged; macOS save sheets offer a Format menu, #866; Attributes fill-rule controls edit the compound when a member is selected, #1197; a marquee needs to touch a path, not just lie inside its fill, #1103; earlier: Live Corners round corners between curved sides, #1144) · **Target:** Adobe Illustrator 2026 (30.x) How VectorCraft's tools, handles, snapping and panels behave next to Illustrator's. A vector illustrator's trust is in the details: where a handle lands, what Shift and Alt do mid-drag, what a smart guide snaps to, @@ -95,7 +95,7 @@ tools, never checked side by side) isn't captured row by row. | Measurement labels while drawing, moving and resizing | partial | sizes and offsets; angle and distance readouts while drawing missing | | Spacing guides (equal distances) | partial | for moves (#394); not for resizing or drawing | | Snapping tolerance preference | done | | -| Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | | +| Snap to Point, Snap to Grid, Snap to Pixel; Pixel Preview | done | #1840: with Snap to Grid on, anchors and objects snap to the grid even when Snap to Pixel is also ticked (#1816 waits for the reporter) | | Snap to Glyph | missing | menu stub | | Tangent and perpendicular snapping for line endpoints (new in 30.0) | missing | | | Snapping panel/popover consolidating the snap options (30.0) | missing | | @@ -124,6 +124,7 @@ tools, never checked side by side) isn't captured row by row. | Transform Objects beside Transform Patterns in the Move, Rotate, Scale, Reflect and Shear dialogs and Transform Each; Reflect across an angle | done | #1448, #1460; off, only the pattern tiles transform; the checkboxes are always enabled (our choice); the link that keeps Transform Each's two scales equal is missing (not documented) | | Split Into Grid dialog: number, height or width, gutter and total for rows and columns, Add Guides, Preview | done | #1462; opens at the selection's size; several paths become one grid painted as the topmost one; a typed height or width keeps the gutter and changes the total (our choice) | | Reference point locator (nine squares) in Control bar and Transform panel | done | | +| Transform panel, Properties and Control bar X/Y/W/H of the anchor points selected with Direct Selection | done | #1839 (fixes #1824): they show and edit the selected points' bounds; a whole-object selection is unchanged | ## Nudging, numeric entry and precision @@ -189,6 +190,8 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | Floating groups stack into sets; sets dock as extra dock columns (a multi-column dock); groups drop anywhere in a set or column, or onto its tabs; a column taller than the window scrolls | done | `window.panel.float {below, above}`, `window.panel.dock {column}` | | Panels outside the app window (another monitor) | missing | single OS window | | Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; New/Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) | +| Toolbar without group labels by default, one or two columns; All Tools drawer by flyout group with shortcuts, its menu (Reset, Basic, Advanced) and Show section | done | #1751; a preference brings the labels back; `toolbar.show` sets the Fill/Stroke, Coloring, Draw Mode and Screen Mode controls | +| Actions panel: a clicked action stays selected for Play Current Selection and Delete Selection | partial | #1853 (part of #1629); editing actions and action shortcuts remain | | Properties panel per context (every selection type's Quick Actions) | partial | | | Contextual task bar | done | | | Control bar with Transform and Stroke popovers | done | | @@ -204,7 +207,7 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut | SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) | | Workspaces (save, reset, switch) | done | | | DXF export/import preserves intended curve closure | done | #1299; R13–2018 closed SPLINE bit set/read; an open curve ending at its start stays open; knots and control points unchanged; native CIRCLE/ARC export and CNC preset remain pending | -| macOS save sheet: choose the format from a menu | done | #866; `apps/vectorcraft/src/mac_save_format.rs`; current suffix selects the initial format, changing it updates the filename; native SVG save and cancel after changing to PDF checked | +| macOS save sheet: choose the format from a menu | missing | #866 (reopened); the menu was withdrawn by #1855 to restore saving: Save As, Save a Copy and the first Save showed no sheet with it. It comes back once the format can be set before the sheet opens | | Preferences: every option acts | partial | ~16 stored but unused (#394) | | Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 | | Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines | @@ -220,6 +223,7 @@ re-measure of the panels added since. Slider rails show on Medium Dark (#1808, # | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | The macOS save sheet's Format menu goes back to missing: #1855 withdrew it to restore saving (#866 reopened). Rows added for the X/Y/W/H of direct-selected anchors (#1839, fixes #1824), the toolbar and All Tools drawer (#1751) and the Actions panel selection (#1853, part of #1629); the snapping row notes Snap to Grid over Snap to Pixel (#1840). Scores unchanged (estimated) | | 2026-10-14 | minor | Align and Distribute act on the anchor points selected with Direct Selection: the points of one path align to each other, not to the artboard, and Distribute spaces them (#1641, fixes #1636; aligning to a key anchor is still missing); Delete removes every selected anchor of a closed path (it used to remove the wrong ones), removes objects selected whole along with the selected anchors of others, and keeps the remainder selected so a second Delete removes it (#1674); In isolation mode Direct Selection and Group Selection pick only inside the isolated object (#1666); Escape cancels a Direct Selection drag (#1803, #1743); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); A filled Pencil path keeps a stroke of None (#1804, part of #1679); With the Pen, a press that moves under 3 screen pixels is a click (a corner without handles), and a Precise Cursors command toggles the existing preference and can take a shortcut (#1663); The [ and ] keys reach the Blob Brush, the Eraser and the Symbolism tools in the app (#1734, part of #1661); In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635); Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756); Select › Same matches type on its characters' fill and stroke (#1746, #1737); Fields commit what was typed when you click away or move to another field, as Enter does; Escape still discards it (#1787); Up and Down move through the command search results and Enter runs the highlighted one (#1798, #1757); Slider rails show on Medium Dark (#1808, #1662); On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation); Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed); the guide-position row (#1659) is now "not scored": the documentation found doesn't establish it, and it is kept as a convenience pending the owner's decision; scores unchanged | | 2026-10-13 | minor | Pathfinder's Trim, Merge and Crop remove the objects' strokes, and Merge joins same-fill objects whatever their strokes were, as the public Pathfinder page describes (#1572, fixes #1570); Offset Path with an inset larger than the shapes says the offset leaves nothing, instead of asking to select paths (#1591, fixes #1590); The Symbols panel's New Symbol opens Symbol Options to name the symbol first, and Alt-click skips it (#1566, fixes #1560); Tool flyouts are as wide as their longest translated name and shortcut need, so names no longer run into shortcuts in Spanish and other languages (#1650, fixes #1616); Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't); The Transform panel shows a selected ruler guide's X or Y, and typing a value moves it (#1659, part of #1597). This is not established by the documentation we could find: the public help pages on guides, rulers and the Transform, Control and Properties panels (read again on 2026-10-14) describe selecting and dragging unlocked guides and the panels' X and Y for a selection, not a guide's position in a panel, and the one public statement of it is a user's [community answer](https://community.adobe.com/questions-652/adding-a-new-custom-guide-785586), which is not documentation. It is kept as a convenience pending the owner's decision, with no parity claim and nothing counted towards a score; Select Behind (Ctrl/Cmd-click) tests each object once, so a deep stack of overlapping objects no longer slows it; same objects in the same order (#1571); Commands that take `ids` run on the objects given with nothing selected, over MCP, the control channel and the CLI too (#1577, fixes #1575); scores unchanged (estimated) | | 2026-10-13 | minor | G1/M11: Artboard panel deletion, protected deletion keys and navigator/tool synchronization before and after deletion; history normalization preserves valid multiselection; history regressions and full workspace CI pass; scores and hours unchanged | From abe89d5176bac389defa16f4887daa60d5fd8d00 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 17:26:22 -0700 Subject: [PATCH 29/37] Type: Show Hidden Characters keeps no text nodes while it is off or no document is open Show Hidden Characters keeps each text object's marks together with the node it laid them out from. Only a frame that drew the overlay changed what it kept. After the option was turned off, or the document was closed, those nodes stayed alive until the overlay was next drawn. The canvas now takes the kept marks at the start of each frame and puts them back only when it draws the overlay. A frame that does not draw it, for example with the option off or with no document open, drops the marks and their nodes. A frame that draws the overlay still keeps the marks of the frame before. --- crates/ui-egui/src/canvas.rs | 72 ++++++++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 4 +- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2bf7ea775..7aa8ab790 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -153,6 +153,11 @@ pub fn fit(app: &mut VectorcraftApp, how: &str) { pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let full = ui.available_rect_before_wrap(); + // `hidden_chars` holds the marks Show Hidden Characters kept and the text nodes they were laid + // out from. Only the assignment right before the overlay call below puts them back. A frame + // that does not draw the overlay drops them, for example with the option off, with the edges + // hidden, on the Home screen or with no document open. + let hidden_chars = std::mem::take(&mut app.canvas.hidden_chars); // A document that opened, closed or became active since Home was chosen replaces it. if app.ui.home.is_some_and(|k| k != crate::menus::home_key(app)) { app.ui.home = None; @@ -438,6 +443,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { thread_overlay(app, &painter, &xf); } if app.ui.view.hidden_chars { + app.canvas.hidden_chars = hidden_chars; hidden_chars_overlay(app, &painter, &xf); } } @@ -1530,8 +1536,8 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { let color = Color32::from_rgb(0x4f, 0x9d, 0xff); let font = egui::FontId::proportional(((10.0 * xf.zoom) as f32).clamp(7.0, 18.0)); let kept = &mut app.canvas.hidden_chars; - // `last` holds the marks of the last frame that showed them. The marks of the objects this - // frame doesn't draw are dropped at the end. + // `last` holds the marks of the last frame. The marks of the objects this frame doesn't draw + // are dropped at the end. let mut last = std::mem::take(&mut kept.marks); // The inputs are read before any layout: when fonts load during this frame, the next frame // lays all type out again. @@ -1569,7 +1575,7 @@ fn hidden_chars_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: &Xf) { }); } -/// The Show Hidden Characters marks of the text objects drawn in the last frame that showed them +/// The Show Hidden Characters marks of the text objects drawn in the last frame /// ([`crate::CanvasCache::hidden_chars`]). Each is kept with the object it was laid out from: an /// edited object is a new `Arc` and is laid out again, and all type is laid out again when the /// fonts or the hyphenation exceptions change. @@ -3763,6 +3769,66 @@ mod tests { assert_ne!(drawn(&mut app, &xf), zoomed, "set in the family that arrived"); } + /// The shapes Type → Show Hidden Characters draws in one canvas frame ([`show`]). + fn hidden_char_marks(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let blue = Color32::from_rgb(0x4f, 0x9d, 0xff); + shapes(app, ctx) + .into_iter() + .filter(|s| matches!(s, Shape::Circle(c) if c.fill == blue) || matches!(s, Shape::Text(t) if t.fallback_color == blue)) + .collect() + } + + /// While Show Hidden Characters is on, a canvas frame keeps the marks of the frame before. + /// Turning it off drops the marks with their text nodes, and turning it on again draws the + /// same marks. + #[test] + fn show_hidden_characters_off_releases_the_text_nodes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let text = json!({"x": 20, "y": 60, "text": "one two\nthree four"}); + let id = vectorcraft_doc::NodeId(app.session.execute("text.create", &text).unwrap()["id"].as_u64().unwrap()); + let ctx = egui::Context::default(); + app.run("type.hiddenCharacters", json!({})).unwrap(); + let on = hidden_char_marks(&mut app, &ctx); + assert!(on.len() > 2, "{on:?}"); + // Tests running alongside add fonts and set hyphenation exceptions, which lays all type out + // again: frames are drawn until neither changed between two of them. + let kept = (0..100).any(|_| { + let (inputs, marks) = (app.canvas.hidden_chars.inputs.clone(), app.canvas.hidden_chars.marks[&id].1.clone()); + hidden_char_marks(&mut app, &ctx); + let after = &app.canvas.hidden_chars; + after.inputs == inputs && std::sync::Arc::ptr_eq(&after.marks[&id].1, &marks) + }); + assert!(kept, "a frame keeps the marks of the frame before"); + app.run("type.hiddenCharacters", json!({})).unwrap(); + assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "off"); + assert!(app.canvas.hidden_chars.marks.is_empty(), "no node is kept"); + app.run("type.hiddenCharacters", json!({})).unwrap(); + assert_eq!(hidden_char_marks(&mut app, &ctx), on, "on again: the same marks"); + } + + /// Closing the document drops the marks Show Hidden Characters kept with its text nodes, and + /// the same type in a new document shows the same marks. + #[test] + fn show_hidden_characters_releases_the_text_nodes_of_a_closed_document() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + app.run("type.hiddenCharacters", json!({})).unwrap(); + let open = |app: &mut VectorcraftApp| { + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("text.create", &json!({"x": 20, "y": 60, "text": "one two\nthree four"})).unwrap(); + hidden_char_marks(app, &ctx) + }; + let first = open(&mut app); + assert!(first.len() > 2, "{first:?}"); + assert_eq!(app.canvas.hidden_chars.marks.len(), 1, "the marks are kept with the text object's node"); + app.session.execute("file.close", &json!({})).unwrap(); + assert!(hidden_char_marks(&mut app, &ctx).is_empty(), "the Home screen"); + assert!(app.canvas.hidden_chars.marks.is_empty(), "no node of the closed document is kept"); + assert_eq!(open(&mut app), first, "the same type in a new document"); + } + /// General › Zoom with Mouse Wheel (#394), through the control channel's `ui.wheel`: off, /// the wheel scrolls and Cmd-wheel zooms; on, the wheel zooms about the pointer, Shift-wheel /// scrolls up and down and Cmd/Ctrl-wheel sideways. Alt-wheel (Option on the Mac) and a diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 012decbf2..821c4e167 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -284,8 +284,8 @@ pub struct CanvasCache { pub slices: Option, /// The print tiling's pages for (document uid, revision) (View → Show Print Tiling). pub print_tiling: Option, - /// The marks Type → Show Hidden Characters drew on each text object in the last frame that - /// showed them. + /// The marks Type → Show Hidden Characters drew on each text object in the last frame. A frame + /// that does not draw them drops them ([`canvas::show`]). pub hidden_chars: canvas::HiddenChars, /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). pub selection_box: Option<((u64, u64, bool), Option)>, From 014021577bd98b94f850c8d63c6a886b1d1917c4 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 16:48:31 -0700 Subject: [PATCH 30/37] Web: the status bar shows measured render and UI times instead of 0.0 ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In the web build, now_ms() in crates/ui-egui/src/lib.rs returned 0.0. The status bar read "render 0.0 ms · ui 0.0 ms" however long a frame took, and the Layers panel's budget of 8 ms for the row thumbnails it renders in a frame never ran out, so only its limit of 16 thumbnails applied. now_ms() now returns the milliseconds since its first call, read from the browser's performance.now() through web-time, which the build already has through eframe. The desktop clock is unchanged. In Chrome, after a pan frame of 750 ms over a document of 50,000 paths, the status bar now reads "render 750.7 ms". The Layers panel now renders the thumbnails of 24 groups of 2,000 paths one per frame, where main rendered nine of them in one frame of 217 ms. The canvas has no render worker in the web build, so it still renders on the UI thread and draws the same pixels. Docs: docs/target-app-parity.md. --- Cargo.lock | 1 + crates/ui-egui/Cargo.toml | 5 +++++ crates/ui-egui/src/lib.rs | 7 ++++++- docs/target-app-parity.md | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c5913b505..776c8266d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4976,6 +4976,7 @@ dependencies = [ "vectorcraft-three-d", "vectorcraft-tools", "wat", + "web-time", ] [[package]] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 7591e4905..193832b12 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -33,6 +33,11 @@ serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } +[target.'cfg(target_arch = "wasm32")'.dependencies] +# `now_ms` (src/lib.rs) reads the page's `performance.now()` with this crate in the web build. It is +# MIT OR Apache-2.0 and already in the build through eframe. +web-time = "1" + [dev-dependencies] egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } vectorcraft-testkit = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 821c4e167..87ddb2e9c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -964,6 +964,8 @@ impl VectorcraftApp { } } +/// Returns the milliseconds since the Unix epoch, or in the web build the milliseconds since the +/// first call, read from the page's `performance.now()`. pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { @@ -972,7 +974,10 @@ pub fn now_ms() -> f64 { } #[cfg(target_arch = "wasm32")] { - 0.0 + use std::sync::LazyLock; + use web_time::Instant; + static START: LazyLock = LazyLock::new(Instant::now); + START.elapsed().as_secs_f64() * 1000.0 } } diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 04ef099a7..2472e8b40 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -546,6 +546,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429). + In the web build the status bar's render and UI times are measured with the browser's `performance.now()`, and the Layers panel stops rendering row thumbnails in a frame once they have taken 8 ms. An Alt-drag with the Selection, Direct Selection, Group Selection, Rotate, Scale, Reflect or Shear tool copies the selection on its first move, and the moves after it transform those copies; the previews of the Move, Rotate, Scale, Reflect and Shear dialogs with Copy and Preview on copy the selection once in the same way (#1429). When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). From c44ece311387ad11e0a19ec331b1aea4359c1833 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 13 Oct 2026 05:16:29 -0700 Subject: [PATCH 31/37] Render: the art prepared for Separations Preview and Overprint Preview is kept between frames With Separations Preview on, the renderer copied every node of the document in every frame and separated each of its colors again. With Overprint Preview on, it copied each object with an overprinting fill or stroke in every frame, with the groups and layers that hold it. The copies were new Arcs, so the geometry, stroke outline and raster caches missed for each of them in every frame. In a release build, drawing a 2880x1800 frame of a 50,000-path document again with the same view and Separations Preview took 795 to 908 ms. The renderer now keeps the art it prepared and what it was made from, and in the next frame it uses that art again for each layer, symbol art and member that has not changed. Only the objects that changed are prepared again, with the layers and groups that hold them. All of the art is prepared again when Discard White Overprint changes and, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is registered (cms::profiles_generation). The renderer now draws that frame again in 98 to 107 ms, with the same pixels. Docs: docs/target-app-parity.md. --- crates/color/src/cms/mod.rs | 12 +- crates/engine/tests/color_settings.rs | 42 ++++++ crates/render/src/lib.rs | 6 +- crates/render/src/proof.rs | 178 +++++++++++++++++++++++--- crates/render/src/tests_memory.rs | 143 +++++++++++++++++++++ docs/target-app-parity.md | 1 + 6 files changed, 359 insertions(+), 23 deletions(-) diff --git a/crates/color/src/cms/mod.rs b/crates/color/src/cms/mod.rs index f10b55428..b905e5e55 100644 --- a/crates/color/src/cms/mod.rs +++ b/crates/color/src/cms/mod.rs @@ -13,7 +13,7 @@ pub mod generic; pub mod icc; pub mod lab; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use serde::{Deserialize, Serialize}; @@ -165,6 +165,8 @@ fn generic() -> Arc { } static USER: RwLock>> = RwLock::new(Vec::new()); +/// Counts the profiles [`register_icc`] has registered ([`profiles_generation`]). +static PROFILES_GENERATION: AtomicU64 = AtomicU64::new(0); fn builtin_rgb_cache(name: &str) -> Option> { static CACHE: Mutex>> = Mutex::new(Vec::new()); @@ -251,9 +253,17 @@ pub fn register_icc(bytes: &[u8], name: Option) -> Result u64 { + PROFILES_GENERATION.load(Ordering::Relaxed) +} + /// Profile `name` as an ICC file to embed in an export: any RGB or CMYK profile of [`profiles`] /// (by its current or legacy name), or [`GRAY`]. Built-in profiles are written in code from the /// CMS: the RGB spaces from their primaries, the CMYK spaces as lookup tables sampled from their diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index efc4bd5bb..fa5d95b75 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -56,6 +56,48 @@ fn legacy_profile_name_resolves_in_commands_and_files() { cms::set_active(&before).unwrap(); } +/// Separations Preview follows the Color Settings and the profiles loaded: a renderer draws a gray +/// square on the Black plate through the new working CMYK profile, and the four process plates +/// through a proof profile loaded again under the same name, as a new renderer draws them. +#[test] +fn separations_preview_draws_through_new_color_settings() { + let _g = settings(); + let mut s = session(); + let gray = rect(&mut s, 20.0, 20.0, 160.0, 120.0); + exec(&mut s, "paint.setFill", json!({"ids": [gray.0], "color": "#808080"})); + // The proof and Overprint Preview options the canvas takes from the view state the + // commands set. + let canvas = || RenderOptions { + proof: vectorcraft_render::proof::active_proof(), + overprint_preview: vectorcraft_render::proof::overprint_preview_on(), + background: Some([255, 255, 255, 255]), + ..Default::default() + }; + let frame = |r: &mut Renderer, s: &Session| r.render(&s.doc().unwrap().doc, 400, 300, Affine::IDENTITY, &canvas()).pixels; + exec(&mut s, "view.separationsPreview", json!({"only": "Black"})); + let mut r = Renderer::new(); + let before = frame(&mut r, &s); + let old = cms::active_settings(); + s.execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap(); + let fresh = frame(&mut Renderer::new(), &s); + let warm = frame(&mut r, &s); + cms::set_active(&old).unwrap(); + assert!(fresh != before, "the new profile changes the plate"); + assert!(warm == fresh); + let name = "Separations Proof CMYK"; + cms::register_icc(&cms::icc_bytes(cms::GENERIC_CMYK).unwrap(), Some(name.into())).unwrap(); + exec(&mut s, "view.proofSetup", json!({"target": format!("cmyk:{name}")})); + exec(&mut s, "view.separationsPreview", json!({"plates": ["Cyan", "Magenta", "Yellow", "Black"]})); + let before = frame(&mut r, &s); + cms::register_icc(&cms::icc_bytes(cms::DEVICE_CMYK).unwrap(), Some(name.into())).unwrap(); + let fresh = frame(&mut Renderer::new(), &s); + let warm = frame(&mut r, &s); + exec(&mut s, "view.proofSetup", json!({"target": "workingCmyk"})); + exec(&mut s, "view.separationsPreview", json!({"on": false})); + assert!(fresh != before, "the profile loaded again changes the plates"); + assert!(warm == fresh); +} + /// Raster effects follow the Color Settings: a blurred CMYK fill, and a white square's drop shadow /// in a CMYK color, are drawn again through the new working CMYK profile, in a frame drawn while /// interacting (as during a dialog's preview) and after it. diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a5676805a..f5d9562d3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -490,6 +490,9 @@ pub struct Renderer { isolation: Option<(u64, usize, NodeId, Vec)>, /// Drawing inside the isolated container or a dimmed object: nothing further dims. isolation_settled: bool, + /// The art the last frame drew with Overprint Preview or Separations Preview: the next frame + /// draws it again for the objects that have not changed ([`proof::prepare`]). + proof_art: Option, } /// How deep inline graphics nest (text in a symbol shown inline in text…) before they draw nothing. @@ -592,6 +595,7 @@ impl Renderer { raster: vello_cpu::RasterizerSettings::default(), isolation: None, isolation_settled: false, + proof_art: None, } } @@ -616,7 +620,7 @@ impl Renderer { } // The document's own symbols: proofing rewrites the colors and blend modes of their art. self.check_symbols(doc); - let prepared = proof::prepare(doc, opts); + let prepared = proof::prepare(doc, opts, &mut self.proof_art); let doc: &Document = &prepared; let w = width.clamp(1, u16::MAX as u32) as u16; let h = height.clamp(1, u16::MAX as u32) as u16; diff --git a/crates/render/src/proof.rs b/crates/render/src/proof.rs index 9a274ecfd..10db44cb6 100644 --- a/crates/render/src/proof.rs +++ b/crates/render/src/proof.rs @@ -18,14 +18,15 @@ //! [`crate::RenderOptions`] with [`active_proof`] and [`overprint_preview_on`]. use std::borrow::Cow; -use std::sync::RwLock; +use std::collections::HashMap; +use std::sync::{Arc, RwLock, Weak}; -use vectorcraft_color::Color; use vectorcraft_color::cms::{self, Cms, PROCESS_PLATES}; pub use vectorcraft_color::cms::{Intent, ProofSetup, ProofTarget}; -use vectorcraft_doc::Document; +use vectorcraft_color::{Color, Swatch, SwatchGroup}; pub use vectorcraft_doc::inks::{Inks, Link, Plate, inks, map_document_colors, map_node_colors, plates, spot_color}; use vectorcraft_doc::overprint::multiply_overprints; +use vectorcraft_doc::{Document, Node, NodeId, Shared}; use crate::RenderOptions; @@ -63,29 +64,164 @@ fn plate_color(doc: &Document, c: &Cms, proof: &ProofSetup, visible: &[String], Color::Rgb { r, g, b } } -/// The document as it should be drawn for these options (overprints, separations). -pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions) -> Cow<'a, Document> { - let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s))); - let overprint = overprints(opts) && (doc.layers.iter().any(|l| l.has_overprint()) || doc.symbols.iter().any(|s| s.art.has_overprint())); - if !overprint && seps.is_none() { +/// The art a renderer drew with Overprint Preview or Separations Preview, kept for its next frame +/// ([`prepare`]): the document's layers and symbol art as they were, each beside the art drawn for +/// it, and what that art was made with. The kept nodes keep their addresses: no other node gets +/// one of them while they are kept. +pub(crate) struct Prepared { + inputs: Inputs, + layers: (Vec>, Vec>), + art: (Vec>, Vec>), +} + +/// What [`prepare`] reads besides the art. +#[derive(PartialEq)] +struct Inputs { + discard_white: bool, + plates: Option, +} + +/// What the colors of Separations Preview's plates are made from ([`plate_color`]). +struct Plates { + /// The proof setup, with the visible plates. + proof: ProofSetup, + /// The document's swatches, swatch groups and Spot Colors option. + swatches: Shared>, + groups: Shared>, + spot_use_lab: bool, + /// The color settings, held weakly: no other settings get their address while they are kept. + cms: Weak, + /// [`cms::profiles_generation`]. + profiles: u64, +} + +impl PartialEq for Plates { + fn eq(&self, o: &Self) -> bool { + (&self.proof, &self.swatches, &self.groups, self.spot_use_lab, self.profiles) + == (&o.proof, &o.swatches, &o.groups, o.spot_use_lab, o.profiles) + && Weak::ptr_eq(&self.cms, &o.cms) + } +} + +/// What [`prepare`] does to the fills and strokes of each node: overprinting ones multiply +/// ([`multiply_overprints`]), and with Separations Preview every color shows as the visible plates +/// print it ([`plate_color`]). +struct How<'a> { + doc: &'a Document, + discard_white: bool, + /// Separations Preview: the color settings, the proof setup and the visible plates. + plates: Option<(&'a Cms, &'a ProofSetup, &'a [String])>, +} + +impl How<'_> { + /// Whether [`Self::apply`] can change `n`: with Separations Preview any node, else a node with + /// an overprinting fill or stroke. + fn changes(&self, n: &Node) -> bool { + self.plates.is_some() || n.appearance.items.iter().any(|i| i.overprint()) + } + + /// Change the fills, strokes and other colors of node `a`, which holds no members. + fn apply(&self, a: &mut Arc) { + multiply_overprints(a, self.discard_white); + if let Some((c, proof, visible)) = self.plates { + map_node_colors(Arc::make_mut(a), &mut |col, sw| plate_color(self.doc, c, proof, visible, col, sw)); + } + } +} + +/// The document as it should be drawn for these options (overprints, separations). `kept` holds +/// the art of an earlier frame ([`Prepared`]): when that frame's inputs were the same, each layer, +/// symbol art and member that is still the node its kept art was made from is drawn from that art, +/// with its `Arc`. `kept` then holds this frame's art, or nothing when neither preview is on. +pub(crate) fn prepare<'a>(doc: &'a Document, opts: &RenderOptions, kept: &mut Option) -> Cow<'a, Document> { + if !overprints(opts) { + *kept = None; return Cow::Borrowed(doc); } - let mut d = doc.clone(); - if overprint { - let discard_white = doc.setup.discard_white_overprint; - for l in &mut d.layers { - multiply_overprints(l, discard_white); - } - for s in &mut d.symbols { - multiply_overprints(&mut s.art, discard_white); + let seps = opts.proof.as_ref().and_then(|p| p.separations.as_ref().map(|s| (p, s))); + let c = cms::active(); + let inputs = Inputs { + discard_white: doc.setup.discard_white_overprint, + plates: seps.map(|(proof, _)| Plates { + proof: proof.clone(), + swatches: doc.swatches.clone(), + groups: doc.swatch_groups.clone(), + spot_use_lab: doc.spot_use_lab, + cms: Arc::downgrade(&c), + profiles: cms::profiles_generation(), + }), + }; + let earlier = kept.take().filter(|k| k.inputs == inputs); + let how = How { doc, discard_white: inputs.discard_white, plates: seps.map(|(proof, visible)| (&*c, proof, visible.as_slice())) }; + let none = (vec![], vec![]); + let (layers, art) = earlier.as_ref().map_or((&none, &none), |k| (&k.layers, &k.art)); + let made_layers = prepare_list(&doc.layers, &layers.0, &layers.1, &how); + let symbol_art: Vec> = doc.symbols.iter().map(|s| s.art.clone()).collect(); + let made_art = prepare_list(&symbol_art, &art.0, &art.1, &how); + let same = |made: &[Arc], from: &[Arc]| made.iter().zip(from).all(|(a, b)| Arc::ptr_eq(a, b)); + let out = if same(&made_layers, &doc.layers) && same(&made_art, &symbol_art) { + Cow::Borrowed(doc) + } else { + let mut d = doc.clone(); + d.layers.clone_from(&made_layers); + for (s, a) in d.symbols.iter_mut().zip(&made_art) { + s.art = a.clone(); } + Cow::Owned(d) + }; + *kept = Some(Prepared { inputs, layers: (doc.layers.clone(), made_layers), art: (symbol_art, made_art) }); + out +} + +/// `nodes` as drawn with `how` ([`prepare_node`]), each against its earlier version in `was`: the +/// node at its place when that node has its id, else the node with its id. `made` holds the art +/// drawn for `was`, place by place. +fn prepare_list(nodes: &[Arc], was: &[Arc], made: &[Arc], how: &How) -> Vec> { + let mut places: Option> = None; + nodes + .iter() + .enumerate() + .map(|(i, n)| { + let at = match was.get(i) { + Some(w) if w.id == n.id => Some(i), + _ => places.get_or_insert_with(|| was.iter().enumerate().map(|(j, w)| (w.id, j)).collect()).get(&n.id).copied(), + }; + prepare_node(n, at.and_then(|j| Some((was.get(j)?, made.get(j)?))), how) + }) + .collect() +} + +/// `n` as drawn with `how`: the art of `earlier` (a node and the art drawn for it) when that art +/// was made from `n` itself; else a copy of `n` with its own fills and strokes changed +/// ([`How::apply`]) and its members prepared against those of `earlier` ([`prepare_list`]), or `n` +/// itself when neither changes. +fn prepare_node(n: &Arc, earlier: Option<(&Arc, &Arc)>, how: &How) -> Arc { + if let Some((was, made)) = earlier + && Arc::ptr_eq(was, n) + { + return made.clone(); } - if let Some((proof, visible)) = seps { - let c = cms::active(); - let src = doc.clone(); - map_document_colors(&mut d, &mut |col, sw| plate_color(&src, &c, proof, visible, col, sw)); + let members = n.children().map(|ch| { + let (was, made) = earlier.and_then(|(w, m)| Some((w.children()?.as_slice(), m.children()?.as_slice()))).unwrap_or_default(); + prepare_list(ch, was, made, how) + }); + let same_members = match (&members, n.children()) { + (Some(m), Some(ch)) => m.iter().zip(ch).all(|(a, b)| Arc::ptr_eq(a, b)), + _ => true, + }; + if same_members && !how.changes(n) { + return n.clone(); } - Cow::Owned(d) + let mut own = Node::clone(n); + if let Some(ch) = own.children_mut() { + ch.clear(); + } + let mut a = Arc::new(own); + how.apply(&mut a); + if let (Some(m), Some(ch)) = (members, Arc::make_mut(&mut a).children_mut()) { + *ch = m; + } + a } /// Soft-proof the rendered (premultiplied RGBA8) pixels in place. diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 5d1786012..871e63a36 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -1136,6 +1136,149 @@ fn rasters_follow_the_view_options() { draws_as_new(&mut d, &isolated, &on_white, unchanged); } +/// A document for the proof previews: a cyan square, a magenta circle over it whose black stroke +/// overprints, a white square over the cyan one that overprints, a blurred rectangle in spot color +/// `Spot`, and an instance of symbol `S`, a group holding a yellow square. +fn proof_doc() -> Document { + use vectorcraft_geom::shapes::{ellipse, rectangle}; + let mut d = Document::new(200.0, 100.0); + let orange = Color::cmyk(0.0, 0.6, 1.0, 0.0); + d.swatches.push(vectorcraft_color::Swatch { name: "Spot".into(), paint: Paint::solid(orange), global: true, spot: true }); + let cyan = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 90.0, 90.0)), Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), vec![]); + let black = Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0)); + let ring = vectorcraft_doc::Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), black, 4.0); + let mut circle = Node::path(d.alloc_id(), ellipse(Rect::new(40.0, 20.0, 100.0, 80.0)), ring); + circle.appearance.stroke_mut().unwrap().overprint = true; + let mut white = filled(&mut d, rectangle(Rect::new(20.0, 30.0, 40.0, 50.0)), Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 0.0)), vec![]); + white.appearance.fill_mut().unwrap().overprint = true; + let spot = Paint::Solid { color: orange, swatch: Some("Spot".into()), tint: 1.0 }; + let blur = effect("blur.gaussian", serde_json::json!({"radius": 1.0})); + let spot = filled(&mut d, rectangle(Rect::new(110.0, 10.0, 150.0, 50.0)), spot, vec![blur]); + let yellow = filled(&mut d, rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0)), vec![]); + let art = Node::group(d.alloc_id(), vec![Arc::new(yellow)]); + d.symbols.push(vectorcraft_doc::Symbol { name: "S".into(), art: Arc::new(art) }); + let instance = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((175.0, 75.0)) }); + with_art(d, vec![cyan, circle, white, spot, instance]) +} + +/// Overprint Preview, and Separations Preview showing `plates`, which turns Overprint Preview on +/// too (as the canvas draws them). +fn proof_views(plates: &[&str]) -> (RenderOptions, RenderOptions) { + let overprint = RenderOptions { overprint_preview: true, ..Default::default() }; + let proof = proof::ProofSetup { separations: Some(plates.iter().map(|p| p.to_string()).collect()), ..Default::default() }; + (overprint.clone(), RenderOptions { proof: Some(proof), ..overprint }) +} + +/// [`proof_views`] with every plate of [`proof_doc`]. +fn every_plate() -> (RenderOptions, RenderOptions) { + proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Spot"]) +} + +/// With Overprint Preview or Separations Preview on, a second frame of an unchanged document draws +/// the art the first prepared for the preview: it makes no geometry entries. After an object +/// moves, a frame makes them for the object and its layer only. A frame drawn with neither preview +/// lets go of the art, and each frame drawn with a preview draws what a new renderer draws. +#[test] +fn proof_previews_keep_their_art_between_frames() { + let _fonts = fonts_steady(); + let d = proof_doc(); + let (overprint, separations) = every_plate(); + let frame = |r: &mut Renderer, d: &Document, opts: &RenderOptions| r.render(d, 200, 100, Affine::IDENTITY, opts).pixels; + let geometry = |r: &Renderer| r.geom.keys().copied().collect::>(); + for opts in [&overprint, &separations] { + let mut r = Renderer::new(); + let first = frame(&mut r, &d, opts); + let kept = geometry(&r); + assert!(frame(&mut r, &d, opts) == first); + assert_eq!(geometry(&r), kept, "the second frame makes no geometry entries"); + // The cyan square moves 5 pt right. + let mut moved = d.clone(); + let square = moved.layers[0].children().unwrap()[0].id; + moved.node_mut(square).unwrap().transform(Affine::translate((5.0, 0.0)), false); + assert!(frame(&mut r, &moved, opts) == frame(&mut Renderer::new(), &moved, opts)); + assert_eq!(geometry(&r).difference(&kept).count(), 2, "the square and its layer"); + frame(&mut r, &moved, &RenderOptions::default()); + assert!(r.proof_art.is_none(), "drawn without the previews, the renderer keeps no art for them"); + } +} + +/// With Overprint Preview or Separations Preview on, a renderer draws what a new renderer draws +/// after a change to the art or to what the previews read: an object recolored, Discard White +/// Overprint turned off, a symbol's art recolored (its instance shows the plates of the new art), +/// the spot color redefined, a spot color in a color group redefined, the Spot Colors option set +/// to CMYK values, a plate hidden, Simulate Paper Color, another proof profile, and one preview +/// after the other. +#[test] +fn proof_previews_draw_changes_as_a_new_renderer_does() { + let _fonts = fonts_steady(); + let (overprint, separations) = every_plate(); + let unchanged = |_: &mut Document| {}; + let recolor_square = |d: &mut Document| { + let square = d.layers[0].children().unwrap()[0].id; + d.node_mut(square).unwrap().appearance.set_fill(Paint::solid(Color::cmyk(0.0, 0.0, 1.0, 0.0))); + }; + let recolor_symbol = |d: &mut Document| { + let mut art = (*d.symbols[0].art).clone(); + if let Some(square) = art.children_mut().and_then(|c| c.first_mut()) { + Arc::make_mut(square).appearance.set_fill(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0))); + } + d.symbols[0].art = Arc::new(art); + }; + for opts in [&overprint, &separations] { + draws_as_new(&mut proof_doc(), opts, opts, recolor_square); + draws_as_new(&mut proof_doc(), opts, opts, |d| d.setup.discard_white_overprint = false); + } + draws_as_new(&mut proof_doc(), &separations, &separations, recolor_symbol); + draws_as_new(&mut proof_doc(), &separations, &separations, |d| { + if let Some(s) = d.swatch_mut("Spot") { + s.paint = Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)); + } + }); + // A square in spot color `Lab Spot`, defined in Lab in a color group, on its own plate. + let lab_doc = || { + let mut d = proof_doc(); + let lab = Color::lab(50.0, 60.0, 40.0); + let swatch = vectorcraft_color::Swatch { name: "Lab Spot".into(), paint: Paint::solid(lab), global: true, spot: true }; + d.swatch_groups.push(vectorcraft_color::SwatchGroup { name: "Lab".into(), swatches: vec![swatch] }); + let fill = Paint::Solid { color: lab, swatch: Some("Lab Spot".into()), tint: 1.0 }; + let square = filled(&mut d, vectorcraft_geom::shapes::rectangle(Rect::new(115.0, 60.0, 155.0, 95.0)), fill, vec![]); + with_art(d, vec![square]) + }; + let lab_plate = proof_views(&["Cyan", "Magenta", "Yellow", "Black", "Lab Spot"]).1; + draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| { + if let Some(s) = d.swatch_mut("Lab Spot") { + s.paint = Paint::solid(Color::lab(70.0, -40.0, 30.0)); + } + }); + draws_as_new(&mut lab_doc(), &lab_plate, &lab_plate, |d| d.spot_use_lab = false); + let hidden = proof_views(&["Magenta", "Yellow", "Black", "Spot"]).1; + draws_as_new(&mut proof_doc(), &separations, &hidden, unchanged); + let with = |f: fn(&mut proof::ProofSetup)| { + let mut opts = separations.clone(); + if let Some(p) = &mut opts.proof { + f(p); + } + opts + }; + draws_as_new(&mut proof_doc(), &separations, &with(|p| p.simulate_paper = true), unchanged); + let device = with(|p| p.target = proof::ProofTarget::Cmyk(vectorcraft_color::cms::DEVICE_CMYK.into())); + draws_as_new(&mut proof_doc(), &separations, &device, unchanged); + let cyan = proof_views(&["Cyan"]).1; + draws_as_new(&mut proof_doc(), &overprint, &cyan, unchanged); + draws_as_new(&mut proof_doc(), &cyan, &overprint, unchanged); + // On the Yellow plate the instance is solid while its art is yellow, and blank once it is cyan. + let yellow = proof_views(&["Yellow"]).1; + let mut d = proof_doc(); + let mut r = Renderer::new(); + let instance = |r: &mut Renderer, d: &Document| { + let pixels = r.render(d, 200, 100, Affine::IDENTITY, &yellow).pixels; + Rendered { width: 200, height: 100, pixels }.pixel(175, 75) + }; + assert_eq!(instance(&mut r, &d), [0, 0, 0, 255]); + recolor_symbol(&mut d); + assert_eq!(instance(&mut r, &d), [255, 255, 255, 255]); +} + /// A thumbnail of an object under a filter that treats color channels apart (Color Halftone) is /// made again when the document's color mode changes: CMYK documents screen ink channels, RGB /// documents light ones. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2472e8b40..0ebfc2d02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -543,6 +543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur actual OS display and Caps Lock remain unverified. Behaviour reference: [public tool-pointer documentation](https://helpx.adobe.com/illustrator/using/tools.html#change_tool_pointers). Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). A Precise Cursors command toggles the preference and can take a shortcut (#1663). - **Performance:** 20k shapes + 1k texts render in 27 ms per full-retina frame (7.8 ms zoomed), 7× faster than the first version. The UI thread never blocks. The web build is 7.1 MB gzipped. Print-size images (more than 512 MB decoded, up to 2 GB) draw in the canvas and in raster exports and can be trimmed with Crop Image; an image with a side past 65,535 px is shrunk to fit, a raster export says when an image could not be decoded, and Crop Image's trim leaves CMYK images alone (#1517). On Windows `vectorcraft-cli perf` reports the cores busy and, with `--deep`, its working set, and warns on a busy machine as it does on macOS and Linux (#1502). The renderer composes the two ink planes of a CMYK document that shows transparency into screen colors on up to as many threads as it rasterizes with, instead of on one. At 2880 × 1800 on 8 threads, a page of 300 shapes at 20% to 90% opacity takes 30 ms a frame instead of 55 ms, and 50,000 paths with one at 99% opacity take 258 ms instead of 361 ms (`vectorcraft-cli bench`), with the same pixels. + With Overprint Preview or Separations Preview on, the renderer keeps the art it prepared for the preview from one frame to the next. It prepares again the objects that changed, and all of the art when Discard White Overprint changes or, with Separations Preview, when the plates shown, the proof setup, the swatches, the Spot Colors option or the color settings change or a profile is loaded. It draws the other objects from the art, geometry and stroke outlines it kept for them. The Pathfinder panel, Offset Path, Clean Up, Divide Objects Below, Make and Release Blend, Envelope Distort's Make and Release, Repeat (Radial, Grid, Mirror and Release), Vector Halftone, New and Redefine Symbol, Delete Symbol, erasing with the Symbol Sprayer, Rasterize, Convert to Artboards, Flatten Transparency, Clear Guides, Shape Builder, Live Paint, Release Image Trace, Make and Release Opacity Mask, Delete with direct-selected anchor points, Remove Anchor Points, Cut Path, and the Shaper, Knife, Eraser, Path Eraser, Line Cut, Rectangle Cut, Mirror & Cut and Blob Brush tools add and remove objects in one pass over each layer or group they change, and Outline Stroke and Expand write each new object in the place of the object it replaces (#1429). Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. Delete, Cut, Group, Ungroup, Make and Release Compound Path, Make Clipping Mask, Send to Current Layer, Duplicate, Copy in the Move, Rotate, Scale, Reflect and Shear dialogs, Transform Each with Copy and each move of an Alt-drag take time in proportion to the size of the document (#1429). From 65e2168791e6f46a90134b25d0f38f4b8d7e0457 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 11:13:43 +0700 Subject: [PATCH 32/37] Shape Builder: the gaps closed paths enclose are regions (#1691) --- crates/engine/src/cmd/shaper.rs | 4 ++ crates/engine/src/tests_build.rs | 33 +++++++++++++ crates/pathops/src/lib.rs | 2 +- crates/pathops/src/pathfinder.rs | 19 +++++--- crates/pathops/src/planar.rs | 80 ++++++++++++++++++++++++++------ docs/gaps.md | 8 ++-- 6 files changed, 120 insertions(+), 26 deletions(-) diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 85b852e5a..f8bfe1ef2 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -90,9 +90,13 @@ fn map_of(src: &[Arc]) -> Result { } let arrangement = po::shape_builder(&shapes, true); let bounded = |pieces: Vec| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect(); + // A Shaper Group's faces are what its shapes fill and what its open paths enclose: the gaps + // closed paths enclose are the Shape Builder's only. + let open = po::has_open_path(&shapes); let regions = arrangement .regions .into_iter() + .filter(|r| open || !r.sources.is_empty()) .filter_map(|r| { let bounds = r.path.bounds()?; let path = r.path.to_bezpath(); diff --git a/crates/engine/src/tests_build.rs b/crates/engine/src/tests_build.rs index 914ad2b71..76d137635 100644 --- a/crates/engine/src/tests_build.rs +++ b/crates/engine/src/tests_build.rs @@ -152,6 +152,39 @@ fn shape_builder_regions_query() { assert!(regs.iter().any(|x| x["sources"].as_array().unwrap().len() == 2)); } +/// #1691: four circles in a 2×2 grid, each touching its neighbours, enclose a diamond nothing +/// fills. It is a region, and a drag from a circle into it merges the two. +#[test] +fn shape_builder_merges_the_gap_touching_circles_enclose() { + let mut s = session(); + let mut circles = vec![]; + for (x, y) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0), (100.0, 100.0)] { + let r = s.execute("shape.ellipse", &json!({"x": x, "y": y, "width": 100, "height": 100})).unwrap(); + circles.push(NodeId(r["id"].as_u64().unwrap())); + } + set_fill(&mut s, circles[0], Color::rgb(1.0, 0.0, 0.0)); + s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::>()})).unwrap(); + let r = s.execute("shapeBuilder.regions", &json!({})).unwrap(); + let regs = r["regions"].as_array().unwrap(); + assert_eq!(regs.len(), 5, "{regs:?}"); + assert_eq!(regs.iter().filter(|x| x["sources"].as_array().unwrap().is_empty()).count(), 1, "the diamond"); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[50, 50], [100, 100]]})).unwrap(); + assert_eq!(r["regions"], 2); + let out = ids(&r); + assert_eq!(out.len(), 4, "the merged shape and the three other circles"); + let merged = NodeId(r["merged"].as_u64().unwrap()); + // A circle (its Bézier outline: 7,854.9) and the diamond between the four (2,145.1). + assert!((area(&s, merged) - 10000.0).abs() < 10.0, "{}", area(&s, merged)); + assert_eq!(node(&s, merged).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)), "the look of the circle the drag began on"); + assert_eq!(layer_children(&s), 4); + // A click in the diamond alone makes it a shape of its own. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": circles.iter().map(|i| i.0).collect::>()})).unwrap(); + let r = s.execute("shapeBuilder.merge", &json!({"points": [[100, 100]]})).unwrap(); + assert_eq!(ids(&r).len(), 5); + assert!((area(&s, NodeId(r["merged"].as_u64().unwrap())) - 2146.0).abs() < 10.0); +} + #[test] fn shape_builder_tool_drives_the_command() { let mut s = session(); diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 0e67a96d7..d531b2cb8 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -20,7 +20,7 @@ pub use edit::{ }; pub use offset::{Cap, Join, offset_path, outline_stroke, stroke_region}; pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, region_at, regions}; -pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, interior_point, live_paint, shape_builder}; +pub use planar::{BuilderArrangement, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, has_open_path, interior_point, live_paint, shape_builder}; /// Errors from fallible operations. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index 4ffbde834..7325c8fa8 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -102,7 +102,7 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec { } // Open paths are cutting edges for Divide; the filled-only operations keep their // implicit closure. Reuse Shape Builder's bounded planar map and its fallback. - if op == PathfinderOp::Divide && shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) { + if op == PathfinderOp::Divide && crate::has_open_path(shapes) { return crate::shape_builder(shapes, false) .regions .into_iter() @@ -298,21 +298,22 @@ pub fn merge_regions(regions: &[&Region]) -> PathData { /// outlines ([`merge_regions`]) could leave those edges in, as a hairline gap or a spur, where /// neighbouring faces' outlines had been refit apart. Shapes with open paths (faces lines cut, as /// [`shape_builder`](crate::shape_builder) gives them) merge in their planar map the same way -/// (#893). Either falls back to [`merge_regions`] when it can't place a face. +/// (#893), and so do faces with a gap closed paths enclose, which no shape covers (#1691). Either +/// falls back to [`merge_regions`] when it can't place a face. pub struct FaceMerger { /// The arrangement (`None`: open paths, or the sweep failed) and the number of shapes. arr: Option, shapes: usize, - /// With open paths, the shapes, and their planar map once a merge has needed it. + /// The shapes, and their planar map once a merge has needed it (open paths, or a gap). lines: Option<(Vec, std::cell::OnceCell>)>, } impl FaceMerger { pub fn new(shapes: &[Shape]) -> Self { - let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2); + let has_open = crate::has_open_path(shapes); // The Shape Builder's faces come from the planar map only up to its cap. let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); - let lines = (has_open && segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new())); + let lines = (segments <= crate::SHAPE_BUILDER_MAX_SEGMENTS).then(|| (shapes.to_vec(), std::cell::OnceCell::new())); Self { arr: if has_open { None } else { arrangement(shapes) }, shapes: shapes.len(), lines } } @@ -321,8 +322,8 @@ impl FaceMerger { self.exact(faces).or_else(|| self.planar(faces)).filter(|p| !p.is_empty()).unwrap_or_else(|| merge_regions(faces)) } - /// `faces` merged in the planar map of shapes with open paths: each face found again by a - /// point inside it, and the result checked to cover what they cover. + /// `faces` merged in the planar map of the shapes: each face found again by a point inside + /// it, and the result checked to cover what they cover. fn planar(&self, faces: &[&Region]) -> Option { let (shapes, map) = self.lines.as_ref()?; let map = map.get_or_init(|| crate::planar::map(shapes)).as_ref()?; @@ -335,6 +336,10 @@ impl FaceMerger { } fn exact(&self, faces: &[&Region]) -> Option { + // A gap no shape covers is no face of the arrangement: it merges in the planar map. + if faces.iter().any(|r| r.sources.is_empty()) { + return None; + } let arr = self.arr.as_ref()?; // Each face by its coverage and a point inside it. let mut picks: Vec<(Vec, Point)> = Vec::with_capacity(faces.len()); diff --git a/crates/pathops/src/planar.rs b/crates/pathops/src/planar.rs index a56fbfe34..39b780a99 100644 --- a/crates/pathops/src/planar.rs +++ b/crates/pathops/src/planar.rs @@ -307,8 +307,9 @@ pub fn live_paint(shapes: &[Shape]) -> (Vec, Vec) { build(shapes).unwrap_or_default() } -/// Most segments the Shape Builder sweeps with open paths as edges: the planar map costs far more -/// than the filled areas' arrangement, so past this only the filled areas count. +/// Most segments the Shape Builder sweeps into its planar map (open paths as edges, the gaps closed +/// paths enclose, outline edges): the map costs far more than the filled areas' arrangement, so past +/// this only the filled areas count. pub const SHAPE_BUILDER_MAX_SEGMENTS: usize = 4096; /// The Shape Builder's arrangement: see [`shape_builder`]. @@ -322,20 +323,26 @@ pub struct BuilderArrangement { pub edges: Vec, } +/// Does any of `shapes` have an open subpath (a cutting edge for the Shape Builder)? +pub fn has_open_path(shapes: &[Shape]) -> bool { + shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2) +} + /// Shape Builder regions of `shapes` (back → front), the pieces of their open paths and, with /// `edges`, the pieces of their outlines (what erasing an edge deletes). /// -/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions). Open -/// paths (the caller closes the ones whose fill should count) are cutting edges: a line across a -/// shape splits it, lines around an area enclose a region nothing fills (empty `sources`), and each -/// path with no closed subpath comes back cut wherever another path meets it, as pieces keyed by -/// its index in `shapes`. Outlines are cut the same way. With no open path, more than +/// Closed paths bound regions with their filled areas, as in [`regions`](crate::regions), and the +/// gaps they enclose that nothing fills are regions too (empty `sources`), such as the middle of +/// four circles that touch (#1691). Open paths (the caller closes the ones whose fill should count) +/// are cutting edges: a line across a shape splits it, lines around an area enclose a region +/// nothing fills, and each path with no closed subpath comes back cut wherever another path meets +/// it, as pieces keyed by its index in `shapes`. Outlines are cut the same way. With more than /// [`SHAPE_BUILDER_MAX_SEGMENTS`] segments or a map that can't be built, only the filled areas /// count and there are no pieces. pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement { - let has_open = shapes.iter().flat_map(|s| &s.path.subpaths).any(|sp| !sp.closed && sp.anchors.len() >= 2); + let has_open = has_open_path(shapes); let segments: usize = shapes.iter().flat_map(|s| &s.path.subpaths).map(|sp| sp.segment_count()).sum(); - let planar = ((has_open || edges) && segments <= SHAPE_BUILDER_MAX_SEGMENTS) + let planar = (segments <= SHAPE_BUILDER_MAX_SEGMENTS) .then(|| { let keyed: Vec = shapes.iter().enumerate().map(|(i, s)| Shape::new(s.path.clone(), s.rule, i as u64)).collect(); live_paint(&keyed) @@ -346,11 +353,15 @@ pub fn shape_builder(shapes: &[Shape], edges: bool) -> BuilderArrangement { }; let is_line = |i: u64| shapes.get(i as usize).is_some_and(|s| !s.path.subpaths.iter().any(|sp| sp.closed)); let (lines, outline): (Vec, Vec) = pieces.into_iter().partition(|e| is_line(e.key)); - BuilderArrangement { - regions: if has_open { faces } else { crate::regions(shapes) }, - lines: if has_open { lines } else { Vec::new() }, - edges: if edges { outline } else { Vec::new() }, - } + let regions = if has_open { + faces + } else { + // The filled areas from their arrangement, whose faces merge exactly, then the gaps. + let mut regions = crate::regions(shapes); + regions.extend(faces.into_iter().filter(|f| f.sources.is_empty())); + regions + }; + BuilderArrangement { regions, lines: if has_open { lines } else { Vec::new() }, edges: if edges { outline } else { Vec::new() } } } /// Does `sp`, closed, enclose an area (rather than retrace itself)? The Shape Builder closes the @@ -1182,6 +1193,43 @@ mod tests { assert!((regions.iter().map(area).sum::() - 10000.0).abs() < 1e-6); } + fn circle(cx: f64, cy: f64, r: f64, key: u64) -> Shape { + Shape::new(shapes::ellipse(Rect::new(cx - r, cy - r, cx + r, cy + r)), FillRule::NonZero, key) + } + + #[test] + fn shape_builder_offers_the_gaps_closed_shapes_enclose() { + // Four circles in a 2×2 grid, each touching its neighbours (#1691): the four circles and + // the diamond between them, which nothing fills. + let s = [circle(50.0, 50.0, 50.0, 0), circle(150.0, 50.0, 50.0, 1), circle(50.0, 150.0, 50.0, 2), circle(150.0, 150.0, 50.0, 3)]; + let regions = shape_builder(&s, false).regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect(); + assert_eq!(gaps.len(), 1); + assert!(gaps[0].contains(Point::new(100.0, 100.0)) && !gaps[0].contains(Point::new(50.0, 50.0))); + let diamond = 10000.0 - std::f64::consts::PI * 2500.0; + assert!((area(gaps[0]).abs() - diamond).abs() < 0.01 * diamond, "{}", area(gaps[0])); + assert_eq!(shape_builder(&s, true).regions, regions, "the outline edges don't change them"); + // A ring of four bars that overlap at the corners: the eight pieces of the bars and the + // square they enclose. + let s = + [rect(0.0, 0.0, 300.0, 100.0, 0), rect(200.0, 0.0, 300.0, 300.0, 1), rect(0.0, 200.0, 300.0, 300.0, 2), rect(0.0, 0.0, 100.0, 300.0, 3)]; + let regions = shape_builder(&s, false).regions; + assert_eq!(regions.len(), 9, "{regions:?}"); + let gaps: Vec<&Region> = regions.iter().filter(|r| r.sources.is_empty()).collect(); + assert_eq!(gaps.len(), 1); + assert!((area(gaps[0]).abs() - 10000.0).abs() < 1e-6 && gaps[0].contains(Point::new(150.0, 150.0))); + // The gap merges with a bar in the planar map: the bar's piece and the square, no seam. + let merger = crate::FaceMerger::new(&s); + let bar = regions.iter().find(|r| r.sources == [0] && r.contains(Point::new(150.0, 50.0))).unwrap(); + let merged = merger.merge(&[bar, gaps[0]]); + assert_eq!(merged.subpaths.len(), 1, "{merged:?}"); + assert!((crate::area(&merged, FillRule::NonZero) - 20000.0).abs() < 1e-6); + // Shapes apart or overlapping without a hole enclose no gap. + let s = [circle(50.0, 50.0, 50.0, 0), circle(250.0, 50.0, 50.0, 1), rect(80.0, 0.0, 220.0, 60.0, 2)]; + assert!(shape_builder(&s, false).regions.iter().all(|r| !r.sources.is_empty())); + } + #[test] fn shape_builder_stays_quick_up_to_its_cap() { // A wavy ring of nearly the most segments the map takes, crossed by a few lines. @@ -1194,6 +1242,10 @@ mod tests { }) .collect(); let mut s = vec![Shape::new(PathData::single(SubPath::polyline(&ring, true)), FillRule::NonZero, 0)]; + // Closed paths alone are swept for the gaps they enclose. + let started = std::time::Instant::now(); + assert_eq!(shape_builder(&s, false).regions.len(), 1, "the ring encloses no gap"); + assert!(started.elapsed().as_secs_f64() < 5.0, "{:?}", started.elapsed()); for k in 0..4 { let y = -60.0 + 40.0 * f64::from(k); s.push(line((-150.0, y), (150.0, y + 7.0), 1)); diff --git a/docs/gaps.md b/docs/gaps.md index 51121ac61..b59be1491 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -144,10 +144,10 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall during a drag keeps the drag (#1856, #1719); a template layer dims its images by Dim Images instead of fading the whole layer (#1864, #1683); a failed save or export is reported in a dialog that names the file and stays until closed, the document left modified (#1865, #1685). -- **Missing (noted for reporters, 2026-10-14):** the Shape Builder offers no region for an empty area enclosed - only by closed shapes, such as the middle of four circles that touch (#1691); deleting an interior anchor of an - open path joins across the gap, where the documented behaviour removes the point and its connecting segments. - Inside the estimate below. +- **Done:** the Shape Builder offers the empty areas closed shapes enclose as regions, such as the middle of four + circles that touch, and merges them with the regions around them (#1691). Gap Detection is still missing. +- **Missing (noted for reporters, 2026-10-14):** deleting an interior anchor of an open path joins across the gap, + where the documented behaviour removes the point and its connecting segments. Inside the estimate below. - **Missing:** Shape Builder misses planar regions and targets whole shapes (#937); Transform effect dialog's invisible checkboxes and missing options (#885); PDF text boxes moving on open (#722); SVG units reverting to points (#864); bezier drag preview freezing (#834). - **Impact:** wrong output in everyday work. From eff2ccdc342bff77e811c2215f0ccc1ece529a61 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 05:50:20 +0200 Subject: [PATCH 33/37] Text: draw tab leaders in the tab's gap (fixes #1877) --- crates/engine/src/cmd/tabs.rs | 34 +++++++++++++++++++ crates/text/src/layout.rs | 63 ++++++++++++++++++++++++++++++----- crates/text/src/tests.rs | 27 +++++++++++++++ docs/type-parity.md | 5 +-- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/tabs.rs b/crates/engine/src/cmd/tabs.rs index c62e1821c..766335ca6 100644 --- a/crates/engine/src/cmd/tabs.rs +++ b/crates/engine/src/cmd/tabs.rs @@ -113,4 +113,38 @@ mod tests { s.execute("text.tabs.clear", &json!({})).unwrap(); assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([])); } + + /// #1877: a leader set the way the Tabs panel sets it fills the tab's gap with its dots. + #[test] + fn tab_leader_fills_the_gap_with_its_characters() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "AAA\t20", "size": 12})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "left", "leader": ".", "alignOn": "."}]})).unwrap(); + let st = s.doc().unwrap(); + let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) else { panic!() }; + let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let x_of = |b: usize| lay.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x; + let (from, to) = (x_of(3), x_of(4)); + assert!(to - from > 150.0, "{from}..{to}"); + // Contours drawn inside the gap: only the leader's dots can be there. + let mut dots = 0; + for g in &lay.glyphs { + let mut contours: Vec = vec![]; + for el in g.outline.elements() { + match (el, contours.last_mut()) { + (kurbo::PathEl::MoveTo(_), _) | (_, None) => contours.push(kurbo::BezPath::from_vec(vec![*el])), + (_, Some(c)) => c.push(*el), + } + } + for c in contours { + let r = kurbo::Shape::bounding_box(&c); + if r.x0 > from - 1e-6 && r.x1 < to + 1e-6 { + assert!(r.width() < 3.0 && r.height() < 3.0, "not a dot: {r:?}"); + dots += 1; + } + } + } + assert!(dots > 30, "{dots} dots in a {} pt gap", to - from); + } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index be0edba3a..2cb943d41 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1113,9 +1113,9 @@ impl Pen<'_> { } /// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then -/// every ½ inch). Right, centre and decimal stops align the text that follows (`rest`, up to the -/// next tab) on the stop. -fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> f64 { +/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal +/// stops align the text that follows (`rest`, up to the next tab) on the stop. +fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) { use vectorcraft_doc::TabAlign; let rel = x - origin; let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect(); @@ -1128,14 +1128,52 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S }; let explicit = tabs.iter().find_map(|t| { let at = target(t.position, t.align, t.align_on); - (at > rel + 1e-6).then_some(at) + (at > rel + 1e-6).then_some((at, t.leader.as_str())) }); - let at = explicit.unwrap_or_else(|| { + let (at, leader) = explicit.unwrap_or_else(|| { let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0); let base = rel.max(last); - ((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL + (((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL, "") }); - (at - rel).max(0.0) + ((at - rel).max(0.0), leader) +} + +/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make +/// millions of outlines. +const MAX_LEADER_COPIES: f64 = 4096.0; + +/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated +/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width +/// from `origin` (where tab stops are measured from), so the leaders of lines above one another +/// line up; only whole copies are drawn. Characters the font lacks draw nothing. +fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath { + let mut out = BezPath::new(); + let glyphs: Vec<(u32, f64)> = leader + .chars() + .filter(|c| !c.is_control()) + .map(|c| { + let gid = g.face.glyph_for(c); + (gid, g.face.advance(gid) * g.sx) + }) + .collect(); + let width: f64 = glyphs.iter().map(|(_, a)| a).sum(); + if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) { + return out; + } + let first = ((from - origin) / width - EPS).ceil(); + let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize; + let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((0.0, -g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy); + for k in 0..copies { + let mut pen = origin + (first + k as f64) * width; + for &(gid, adv) in &glyphs { + if gid != 0 { + let m = Affine::translate((pen, y)) * local; + out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el)); + } + pen += adv; + } + } + out } /// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces @@ -1734,8 +1772,9 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio if j + 1 == trimmed { adv -= end_trim; } + let mut leader = ""; if g.ch == '\t' { - adv = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); + (adv, leader) = tab_advance(¶.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]); } else if j < body { if g.is_space() { adv += per_space; @@ -1750,6 +1789,14 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, regions: Optio } let y = baseline - align_shift(g, cx.style_at(g.byte).char_align, line_em, cx.vertical); cx.emit(g, Affine::translate((x, y)), Point::new(x, y), 0.0, adv, li); + if !leader.is_empty() { + // The leader belongs to the tab's glyph: the gap stays one character for the + // caret, selection and hit testing. + let dots = leader_outline(cx.db, g, leader, tab_origin, x, x + adv, y); + if let Some(tab) = cx.out.glyphs.last_mut() { + tab.outline.extend(dots); + } + } x += adv; if j < trimmed { x_end = x; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ec0223b22..e54aac75f 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -466,6 +466,33 @@ fn tab_stops_position_text() { assert!((x_of(&p, 4) - 72.0).abs() < 1e-6); } +#[test] +fn tab_leaders_line_up_from_line_to_line_and_stop_short_of_the_stop() { + use vectorcraft_doc::{TabAlign, TabStop}; + let mut t = point("A\t1\nAAAAA\t2", style(12.0)); + t.para.tabs = vec![TabStop { position: 120.0, align: TabAlign::Left, leader: ".".into(), align_on: '.' }]; + let lay = layout(db(), &t); + // The left edges of the dots of each line's tab. + let dots = |byte: usize| -> Vec { + let tab = lay.glyphs.iter().find(|g| g.byte == byte).unwrap(); + let mut xs: Vec = vec![]; + for el in tab.outline.elements() { + if let kurbo::PathEl::MoveTo(p) = el { + xs.push(p.x); + } + } + xs.sort_by(f64::total_cmp); + xs.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + xs + }; + let (short, long) = (dots(1), dots(9)); + assert!(short.len() > long.len() && long.len() > 10, "{short:?} {long:?}"); + // The longer line's dots are the shorter line's last ones, at the same x. + let tail = &short[short.len() - long.len()..]; + assert!(tail.iter().zip(&long).all(|(a, b)| (a - b).abs() < 1e-6), "{tail:?} {long:?}"); + assert!(short.last().is_some_and(|&x| x < 120.0)); +} + /// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when /// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. fn japanese_fonts() -> bool { diff --git a/docs/type-parity.md b/docs/type-parity.md index b1c57d910..7ece6d8ee 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -1,6 +1,6 @@ # Type parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (tab leaders are drawn in the tab's gap, #1877; type in symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, #1738; score unchanged; Escape while typing ends the edit, #1686; Select › Same on type, #1737; `justify` when creating point type, #1748; scores unchanged; OpenType stylistic sets, alternates, figure styles, superscript and subscript as character features, #1607; a wide word in non-rectangular area type moves down, #1615; scores unchanged; Character Styles New/Redefine capture the first selected character at run boundaries and end prior typing before capture; boundary and typing-history input tests and full workspace CI pass; scores and hours unchanged; Character styles retain explicit Auto leading, propagate resets and preserve local leading overrides; scores and hours unchanged; Linux font folders from fontconfig, #1529; commands and MCP `add_text` report area type that does not fit its frame, #1471; Tabs panel stop drags, #1458; Fit Headline in a frame one line tall, #1444; frames and paths beyond a billion points hold no text, #1451; scores unchanged; missing glyphs are looked for again after a font rescan, #1376; scores unchanged; Full-name font matching (#1258); Windows font-service QA pending; scores unchanged; Middle Eastern digit types: Character panel menu › Digits, `{digits}`; the font menu's script filter lists Thai fonts; Thai, Lao, Khmer and Myanmar lines break between words; the caret steps over whole clusters such as Thai consonants with their marks; Thai typed in a font without Thai falls back to a font made for Thai; earlier: Proportional Metrics in vertical type, `vpal`, #966) · **Target:** Adobe Illustrator 2026 (30.x) Type is Illustrator's largest single area by use (weight 13 of 105 across "Type core" and "Type advanced" in [target-app-parity.md](target-app-parity.md)). This checklist lists the features one by one. Status is from the @@ -34,7 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Every-line and Single-line composers | done | tuned by eye, never compared side by side | | Hyphenation… and Justification… options | missing | | | Optical Margin Alignment | missing | menu stub | -| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leader drawing not verified against the docs | +| Tab stops and leaders; Tabs panel | partial | Tabs panel and stops with leaders in the model (`text.tabs.set`); a stop drags from where the press landed (#1458); leaders fill the tab's gap, repeated on a grid so they line up from line to line (#1877) | | Hidden characters (Show Hidden Characters) | done | `type.hiddenCharacters` | | Spell check (Check Spelling, Auto Spell Check, custom dictionary) | missing | menu stubs; needs an openly licensed dictionary | | Smart Punctuation, Change Case, Find & Replace | done | | @@ -58,6 +58,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Tab leaders are drawn: the leader's characters fill the tab's gap in the tab's font and size, whole copies on a grid from the frame's edge so leaders line up across lines (fixes #1877); scores and hours unchanged | | 2026-10-14 | minor | Type inside symbols, pattern tiles, graph designs and opacity masks gets its layout bounds back on opening a native file, so warped type in a symbol looks the same after save and reopen (#1833, #1738). Score unchanged (estimated) | | 2026-10-14 | minor | Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802, #1686, #1656); Select › Same matches type on its characters' fill and stroke (#1746, #1737); `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748); scores and hours unchanged | | 2026-10-13 | minor | Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615); Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607); scores and hours unchanged | From 6bc022207469c3d8067ab3b1e6a367c37a82cfb9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 11:17:14 +0700 Subject: [PATCH 34/37] #1723 follow-up: pattern tiles are drawn again after a brush edit or new Color Settings A tile was kept while the pattern's art and what it shows were unchanged, but a brush the art is stroked with lives in the document's other data, and every tile is drawn through the color settings. A tile whose art reads more than itself now also remembers the document's other data, and every tile remembers the color settings it was drawn through (both weakly). --- crates/doc/src/shared.rs | 5 ++++ crates/engine/tests/color_settings.rs | 35 +++++++++++++++++++++++++++ crates/render/src/pattern.rs | 32 ++++++++++++++++++------ crates/render/src/tests_memory.rs | 18 ++++++++++++-- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/shared.rs b/crates/doc/src/shared.rs index 0a7eefc3b..c1a113883 100644 --- a/crates/doc/src/shared.rs +++ b/crates/doc/src/shared.rs @@ -19,6 +19,11 @@ impl Shared { pub fn ptr_eq(a: &Self, b: &Self) -> bool { Arc::ptr_eq(&a.0, &b.0) } + /// A weak reference to the value: it keeps the allocation, so the value's address names it + /// while the reference is kept, but not the value. + pub fn downgrade(this: &Self) -> std::sync::Weak { + Arc::downgrade(&this.0) + } } impl std::ops::Deref for Shared { diff --git a/crates/engine/tests/color_settings.rs b/crates/engine/tests/color_settings.rs index fa5d95b75..bd512abe3 100644 --- a/crates/engine/tests/color_settings.rs +++ b/crates/engine/tests/color_settings.rs @@ -127,6 +127,41 @@ fn raster_effects_draw_through_new_color_settings() { cms::set_active(&old).unwrap(); } +/// Pattern tiles follow the Color Settings: a fill of a pattern in a CMYK color is drawn through +/// the new working CMYK profile in the next frame, as a renderer on a new thread (with no tiles of +/// its own yet) draws it. +#[test] +fn pattern_tiles_draw_through_new_color_settings() { + use std::sync::Arc; + use vectorcraft_color::{Color, Paint}; + use vectorcraft_doc::pattern::{PatternDef, pattern_paint}; + use vectorcraft_doc::{Appearance, Document, Node}; + use vectorcraft_geom::Rect; + use vectorcraft_geom::shapes::rectangle; + let _g = settings(); + let old = cms::active_settings(); + let mut d = Document::new(200.0, 100.0); + let cyan = Appearance::basic(Paint::solid(Color::cmyk(1.0, 0.0, 0.0, 0.0)), Paint::None, 0.0); + let square = Node::path(d.alloc_id(), rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), cyan); + let mut def = PatternDef::new("Checks", vec![Arc::new(square)]); + def.tile = Rect::new(0.0, 0.0, 20.0, 20.0); + d.patterns.push(def); + let checks = Appearance::basic(pattern_paint("Checks"), Paint::None, 0.0); + let filled = Node::path(d.alloc_id(), rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), checks); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, filled).unwrap(); + let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }; + let frame = |r: &mut Renderer, d: &Document| r.render(d, 200, 100, Affine::IDENTITY, &opts).pixels; + let mut r = Renderer::new(); + let before = frame(&mut r, &d); + session().execute("edit.colorSettings", &json!({"cmyk": cms::DEVICE_CMYK})).unwrap(); + let warm = frame(&mut r, &d); + let fresh = std::thread::scope(|t| t.spawn(|| frame(&mut Renderer::new(), &d)).join().unwrap()); + cms::set_active(&old).unwrap(); + assert!(fresh != before, "the new profile changes the frame"); + assert!(warm == fresh, "drawn as a renderer with no tiles draws it"); +} + /// Live objects follow the Color Settings: in the next frame, a blend of an RGB red and a CMYK cyan, /// the same blend in an envelope, an envelope that distorts the appearance of a CMYK cyan rectangle /// with Revolve, and a gradient mesh in CMYK colors draw through the new working CMYK profile, and diff --git a/crates/render/src/pattern.rs b/crates/render/src/pattern.rs index 711730a55..c21103be0 100644 --- a/crates/render/src/pattern.rs +++ b/crates/render/src/pattern.rs @@ -7,8 +7,9 @@ //! pattern name and raster scale (and ink plane, see [`crate::ink`]) within //! [`lru::PATTERN_BUDGET`]. A tile is used while the pattern's definition is the one it was //! rasterized from (art `Arc` identity, tile, tile type, overlap): the pattern's "revision", and, -//! when its art shows more than itself (a symbol, another pattern, an image, type), while what -//! the document holds of those is what it held then ([`Deps`]). +//! when its art shows more than itself (a symbol, another pattern, an image, type, a brush), while +//! what the document holds of those is what it held then ([`Deps`]), and while the color settings +//! are the ones it was drawn through. //! //! Pattern editing mode (`Document::pattern_edit`) draws only the temporary tile layer, with //! dimmed copies of it at the neighbouring tile positions and the tile edge. @@ -17,6 +18,7 @@ use std::cell::RefCell; use std::hash::{Hash, Hasher}; use std::sync::{Arc, Weak}; +use vectorcraft_color::cms::Cms; use vectorcraft_doc::pattern::{Overlap, PatternDef, TileType}; use vectorcraft_doc::{Document, Node}; use vectorcraft_geom::{Affine, Rect, Shape}; @@ -35,13 +37,17 @@ const MAX_TILE_PX: f64 = 2048.0; type TileKey = (String, u64); /// What a tile was drawn from besides its pattern's own art, for art that shows more than itself: -/// the art of every pattern and symbol and the pixels of every image in the document, and the -/// fonts. Editing a symbol leaves the art of a pattern that shows it as it was, so the art alone -/// kept the tile from before (#1723). Held weakly: a weak reference keeps the allocation, so no -/// other value gets one of these addresses while the tile is kept. +/// the art of every pattern and symbol and the pixels of every image in the document, the +/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art +/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held +/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses +/// while the tile is kept. struct Deps { art: Vec>, pixels: Vec>>, + /// shortcut: any change to the document's other data draws the tile again, not only a brush + /// edit; compare the brush library alone if that ever costs a frame. + unknown: Weak>, fonts: u64, } @@ -58,6 +64,7 @@ impl Deps { Self { art: Self::art(doc).map(Arc::downgrade).collect(), pixels: Self::pixels(doc).map(Arc::downgrade).collect(), + unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown), fonts: vectorcraft_text::FontDb::global().generation(), } } @@ -65,6 +72,7 @@ impl Deps { /// Does `doc` hold what the tile was drawn from? fn current(&self, doc: &Document) -> bool { self.fonts == vectorcraft_text::FontDb::global().generation() + && std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown) && self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr)) && self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr)) } @@ -74,6 +82,8 @@ struct TileEntry { art: Vec>, /// `None` for art that shows only itself. deps: Option, + /// The color settings the tile was drawn through, held weakly as [`Deps`] holds its values. + cms: Weak, params: (Rect, TileType, Overlap), sx: f64, sy: f64, @@ -199,13 +209,19 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let scale = raster_scale(s, (pw, ph)); let params = (def.tile, def.tile_type, def.overlap); let key = (name.to_string(), scale.to_bits()); + let cms = vectorcraft_color::cms::active(); let hit = CACHE.with(|c| { let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; let t = tiles.tick(); tiles .get(&key, t) - .filter(|e| e.params == params && same_art(&e.art, &def.art) && e.deps.as_ref().is_none_or(|d| d.current(doc))) + .filter(|e| { + e.params == params + && same_art(&e.art, &def.art) + && std::ptr::eq(e.cms.as_ptr(), Arc::as_ptr(&cms)) + && e.deps.as_ref().is_none_or(|d| d.current(doc)) + }) .map(|e| (e.pixmap.clone(), e.sx, e.sy)) }); let (pm, sx, sy) = match hit { @@ -224,7 +240,7 @@ pub(crate) fn set_pattern_paint(ctx: &mut RenderContext, name: &str, xf: Affine, let mut c = c.borrow_mut(); let tiles = &mut c.tiles[ink as usize]; let (t, bytes) = (tiles.tick(), size_of::() + w as usize * h as usize * 4); - tiles.insert(key, TileEntry { art: def.art.clone(), deps, params, sx, sy, pixmap: pm.clone() }, bytes, t); + tiles.insert(key, TileEntry { art: def.art.clone(), deps, cms: Arc::downgrade(&cms), params, sx, sy, pixmap: pm.clone() }, bytes, t); }); (pm, sx, sy) } diff --git a/crates/render/src/tests_memory.rs b/crates/render/src/tests_memory.rs index 871e63a36..075443579 100644 --- a/crates/render/src/tests_memory.rs +++ b/crates/render/src/tests_memory.rs @@ -941,8 +941,8 @@ fn rasters_draw_pattern_edits() { } /// #1723: a pattern's tile is drawn again when what its art shows changes while the art itself -/// doesn't: a symbol an instance in it shows, or another pattern it is painted with. So is a -/// raster effect on an object painted with such a pattern. +/// doesn't: a symbol an instance in it shows, another pattern it is painted with, or a brush it is +/// stroked with. So is a raster effect on an object painted with such a pattern. #[test] fn pattern_tiles_draw_edits_to_what_their_art_shows() { let _fonts = fonts_steady(); @@ -981,6 +981,20 @@ fn pattern_tiles_draw_edits_to_what_their_art_shows() { edit_draws_as_new(doc("Stamps", blur()), new_symbol); edit_draws_as_new(doc("Outer", vec![]), new_checks); edit_draws_as_new(doc("Outer", blur()), new_checks); + // Pattern `Brushed` is a line stroked with a calligraphic brush; the brush gets wider. + let mut d = Document::new(200.0, 100.0); + let mut stroke = vectorcraft_doc::Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0); + stroke.stroke_mut().unwrap().brush = Some("3 pt. Round".into()); + let brushed = Node::path(d.alloc_id(), vectorcraft_geom::shapes::line((2.0, 10.0).into(), (18.0, 10.0).into()), stroke); + d.patterns.push(tile("Brushed", brushed)); + let painted = filled(&mut d, rectangle(Rect::new(10.0, 10.0, 190.0, 90.0)), pattern_paint("Brushed"), vec![]); + edit_draws_as_new(with_art(d, vec![painted]), |d| { + let mut lib = vectorcraft_brush::library(d); + if let vectorcraft_brush::BrushKind::Calligraphic(c) = &mut lib[0].kind { + c.size = 12.0; + } + vectorcraft_brush::store(d, &lib); + }); } /// Raster effects are made again when a symbol shown inside another symbol changes (a group's drop From f9b051d3d17f5e21913b14a1b2e45f3e0c213598 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 11:15:13 +0700 Subject: [PATCH 35/37] Fix #1866: Preferences and Save PDF highlight the section shown The section lists asked for a selectable button and then turned its frame off while the pointer was elsewhere, which also dropped the selected row's fill: the section shown looked like every other row. The selected row now keeps its fill. --- crates/ui-egui/src/dialogs/save_pdf.rs | 2 +- crates/ui-egui/src/prefs_dialog.rs | 30 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index b76e9d654..fcf5105f3 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -377,7 +377,7 @@ pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str], for s in sections { let sel = *s == section; let label = egui::RichText::new(tl!(*s)).size(12.5).color(if sel { t.text_strong } else { t.text }); - if ui.add(egui::Button::selectable(sel, label).frame_when_inactive(false).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() { + if ui.add(egui::Button::selectable(sel, label).min_size(egui::vec2(LIST_WIDTH - 12.0, 24.0))).clicked() { d.fields.insert("__section".into(), json!(s)); } } diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index a4b049c9d..c0182b177 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -167,7 +167,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { for c in PREF_CATEGORIES { let sel = *c == cat; let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text }); - let b = egui::Button::selectable(sel, text).frame_when_inactive(false).min_size(egui::vec2(184.0, 24.0)); + let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0)); if ui.add(b).clicked() { d.fields.insert("__category".into(), json!(c)); } @@ -599,6 +599,34 @@ mod tests { assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true)); } + /// #1866: the category shown is highlighted in the list while the pointer is elsewhere. + #[test] + fn the_category_shown_is_highlighted() { + fn rects(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) { + match s { + egui::Shape::Rect(r) => out.push((r.rect, r.fill)), + egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)), + _ => {} + } + } + let mut a = app(); + open(&mut a, Some("Units")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // The window sizes itself on the first frame and draws on the second. + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx())); + out.textures_delta.clear(); + let mut shown = vec![]; + out.shapes.iter().for_each(|c| rects(&c.shape, &mut shown)); + // The list's rows, top to bottom: only the fourth (Units) is filled. + let rows: Vec<_> = shown.iter().filter(|(r, _)| r.width() == 184.0 && r.height() < 30.0).collect(); + assert_eq!(rows.len(), PREF_CATEGORIES.len(), "{shown:?}"); + let filled: Vec = rows.iter().enumerate().filter(|(_, (_, c))| c.a() > 0).map(|(i, _)| i).collect(); + assert_eq!(filled, vec![3], "{rows:?}"); + } + /// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it /// changes, and its checkbox is dimmed unless a unit is Picas. #[test] From 6fd31d4dbd0cdc3a3b0c26457faf1feabf97ae2a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 11:17:15 +0700 Subject: [PATCH 36/37] MCP docs: a failed batch leaves the files its steps wrote The rollback of a failed command.batch (#1862) puts the documents back, not the disk. Say so, and how to keep a batch from writing unless it succeeds. --- docs/mcp.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index 9e67ca42d..b6b9eb729 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2124,7 +2124,9 @@ the Actions panel leaves the date out: playing it later dates the document then) command records the date it used (and any value it takes from the preferences) in its step of the batch's journal entry. A batch may open, switch, close or revert documents: each document it edits gets one undo step, the batch is journaled whichever document it ends in, and an error rolls every document back (the ones it opened close, the ones -it closed or reverted come back). +it closed or reverted come back). The rollback puts the documents back, not the disk: files its steps wrote (a save, a +Save As, an export) stay written, new files included, as Undo leaves a saved file on disk. A caller that wants nothing +written unless the whole batch succeeds puts the saves and exports last, or runs them after the batch. ```json {"name":"run_command","arguments":{"command":"file.info","params":{"author":"Ada","keywords":"poster, fair","rating":4,"copyrightStatus":"copyrighted","copyrightNotice":"© 2026 Ada"}}} From 7f1ad31a7bd43b672d6c19be760c5efa12bdca39 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Wed, 14 Oct 2026 11:32:15 +0700 Subject: [PATCH 37/37] Fix #1881: a Ctrl/Cmd drag while typing keeps what was typed With the Type tool editing, a press with Ctrl (Cmd on the Mac) lends the Selection tool for the drag. Its drag joined the open typing session, so its preview started from the document before the typing: the type moved or scaled and lost what had just been typed. The typing is now kept as its own undo step before the lent tool takes the press, as Undo already does, and typing goes on after the drag. --- crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/tests_textedit.rs | 24 ++++++++++++++++++++++++ crates/engine/src/tooling.rs | 6 ++++++ 3 files changed, 31 insertions(+) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 5fb39922c..dab8c0c7c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -457,6 +457,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String { (2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } +pub(crate) use edit::{settle_interaction, typing_in_progress}; pub(crate) use vectorcraft_doc::range::parse_range; /// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see diff --git a/crates/engine/src/tests_textedit.rs b/crates/engine/src/tests_textedit.rs index a306f9533..68293247f 100644 --- a/crates/engine/src/tests_textedit.rs +++ b/crates/engine/src/tests_textedit.rs @@ -150,6 +150,30 @@ fn undo_while_typing_takes_back_only_the_typing_and_redo_returns_it() { assert_eq!(obj(&s, id).plain_text(), "abc!"); } +/// #1881: a Cmd/Ctrl drag while typing (the Selection tool lent to the Type tool) moves the type +/// and keeps what was typed, and typing goes on after it. +#[test] +fn a_cmd_drag_while_typing_moves_the_type_and_keeps_the_typing() { + let mut s = session(); + let v = ViewInfo::default(); + s.select_tool("type", v).unwrap(); + click(&mut s, 200.0, 200.0); + let id = s.doc().unwrap().selection.objects[0]; + s.tool_text("Hello", v).unwrap(); + let before = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + let (at, cmd) = (before.center(), Mods { cmd: true, ..Default::default() }); + for (kind, dx) in [(PointerKind::Down, 0.0), (PointerKind::Drag, 100.0), (PointerKind::Up, 100.0)] { + s.pointer(&PointerEvent::new(kind, at.x + dx, at.y).with_mods(cmd), v).unwrap(); + } + assert_eq!(obj(&s, id).plain_text(), "Hello", "the typing is kept"); + let moved = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert!(moved.x0 - before.x0 > 50.0, "the type moved: {before:?} → {moved:?}"); + s.tool_text("!", v).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "Hello!"); + let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert!((after.x0 - moved.x0).abs() < 1e-6, "and stays where it was moved: {after:?}"); +} + #[test] fn undo_during_a_drag_takes_back_only_the_drag() { let mut s = session(); diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index dcb6b3a29..d67edda72 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -115,6 +115,12 @@ impl Session { return *ev; } let Some(id) = self.lent_tool(self.tool.id()) else { return *ev }; + // Typing in progress is kept as its own step first: the lent tool's drag previews from the + // document its interaction started from, which would drop the typing (#1881). + if crate::cmd::typing_in_progress(self) { + // Fails only without a document, and then there is no typing to keep. + let _ = crate::cmd::settle_interaction(self); + } let lent = self.make_tool(id); self.lender = Some(std::mem::replace(&mut self.tool, lent)); PointerEvent { mods: Mods { cmd: false, ..ev.mods }, ..*ev }