From c95a4d901f01cea077732fbf66403fbd72c2ac16 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 23:17:31 -0800 Subject: [PATCH 01/35] Brushes: built-in Ink and Wash library with tapered ink, brush pen and wash brushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Window › Brush Libraries gains Ink and Wash after Art Ribbons: Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers. The art brushes take the stroke color as tints. The names, shapes and opacities are VectorCraft's own. Docs: docs/mcp.md, docs/gaps.md, docs/roadmap.md, docs/target-app-parity.md. --- crates/engine/src/cmd/brushlib.rs | 73 ++++++++ crates/engine/src/tests/brushlib.rs | 158 +++++++++++++++++- crates/ui-egui/src/i18n/bg.tsv | 5 + crates/ui-egui/src/i18n/bs.tsv | 5 + crates/ui-egui/src/i18n/ca.tsv | 5 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/da.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fi.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/hi.tsv | 5 + crates/ui-egui/src/i18n/hu.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ka.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/sv.tsv | 5 + crates/ui-egui/src/i18n/tr.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/vi.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + .../src/panels/tests_brush_libraries.rs | 21 +++ docs/gaps.md | 4 +- docs/mcp.md | 2 +- docs/roadmap.md | 2 +- docs/target-app-parity.md | 8 +- 34 files changed, 391 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 300d9f6aa1..40ffb7840a 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -168,6 +168,7 @@ pub struct BuiltinBrushLibrary { /// The built-in brush libraries, in menu order: original brushes generated in code. pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, + BuiltinBrushLibrary { id: "ink-and-wash", name: "Ink and Wash", make: ink_and_wash }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes }, @@ -264,6 +265,78 @@ fn art_ribbons() -> BrushLibrary { built("Art Ribbons", doc, brushes) } +/// A band between the edges y = `top(x)` and y = `bottom(x)` for x in `from`..`to`, in `n` steps. +fn between(from: f64, to: f64, n: usize, top: impl Fn(f64) -> f64, bottom: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, top(x))).collect(); + pts.extend(xs.iter().rev().map(|&x| Point::new(x, bottom(x)))); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// Path `p` filled with gray `hex` at `opacity`: one layer of a wash. +fn wash(p: PathData, hex: &str, opacity: f32) -> Node { + let mut n = filled(p, Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK))); + n.opacity = opacity; + n +} + +/// How much of a wash's width is left at x: rounded ends, full width from 8 pt in. +fn wash_ends(x: f64) -> f64 { + let ends = ((x / 8.0).min(1.0) * ((100.0 - x) / 8.0).min(1.0)).max(0.0); + 0.4 + 0.6 * ends.sqrt() +} + +/// Ink and Wash: Tapered Ink, broad near its start and thinning to a fine tip; Brush Pen, a +/// rounded nib whose size follows the pen pressure; and two washes of translucent grays, Soft Wash +/// with faint edges and Pooling Wash with a darker rim and two light breaks. Black and grays take +/// the stroke color as tints of it. +fn ink_and_wash() -> BrushLibrary { + let tapered = { + let half = |x: f64| 3.0 * (x / 22.0).min(1.0).powf(0.6) * ((100.0 - x) / 78.0).min(1.0).powf(1.4); + black(between(0.0, 100.0, 48, |x| -0.85 * half(x), |x| 1.15 * half(x))) + }; + let pen = Brush { + name: "Brush Pen".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 40.0, + roundness: 75.0, + size: 5.0, + variation: [0.0, 0.0, 2.5], + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]), + }), + }; + let soft = { + let top = |x: f64| -(4.5 + 0.5 * (x * 0.21).sin() + 0.3 * (x * 0.57 + 1.3).sin()) * wash_ends(x); + let bottom = |x: f64| (4.5 + 0.45 * (x * 0.26 + 2.1).sin() + 0.3 * (x * 0.69 + 0.4).sin()) * wash_ends(x); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#404040", 0.4), + wash(between(2.0, 98.0, 32, |x| top(x) - 0.7 - 0.3 * (x * 0.43).sin().abs(), |x| top(x) + 0.5), "#404040", 0.2), + wash(between(3.0, 97.0, 32, |x| bottom(x) - 0.5, |x| bottom(x) + 0.6 + 0.35 * (x * 0.37 + 1.0).sin().abs()), "#404040", 0.18), + wash(shapes::ellipse(Rect::new(21.0, -2.4, 39.0, 0.8)), "#e6e6e6", 0.22), + wash(shapes::ellipse(Rect::new(61.0, -0.3, 75.0, 2.3)), "#e6e6e6", 0.18), + ]) + }; + let pooling = { + let top = |x: f64| -(4.0 + 0.4 * (x * 0.18 + 0.7).sin() + 0.2 * (x * 0.53).sin()) * wash_ends(x); + let bottom = |x: f64| (4.0 + 0.35 * (x * 0.23 + 1.9).sin() + 0.25 * (x * 0.61 + 0.2).sin()) * wash_ends(x); + let rim = |x: f64, k: f64, phase: f64| 0.35 + k * (0.5 + 0.5 * (x * 0.29 + phase).sin()); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#4d4d4d", 0.35), + wash(between(1.0, 99.0, 40, top, |x| top(x) + rim(x, 0.45, 0.0)), "#262626", 0.55), + wash(between(1.0, 99.0, 40, |x| bottom(x) - rim(x, 0.5, 1.1), bottom), "#262626", 0.55), + wash(polygon(&[(34.0, -2.6), (39.0, -2.8), (36.0, 2.7), (31.0, 2.5)]), "#e6e6e6", 0.2), + wash(polygon(&[(70.0, -2.4), (74.0, -2.5), (72.0, 2.6), (68.0, 2.4)]), "#e6e6e6", 0.18), + ]) + }; + let brushes = vec![ + art("Tapered Ink", tapered, Colorization::Tints), + pen, + art("Soft Wash", soft, Colorization::Tints), + art("Pooling Wash", pooling, Colorization::Tints), + ]; + built("Ink and Wash", Document::new(100.0, 100.0), brushes) +} + fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) } } diff --git a/crates/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 491a471088..03f5c5aeb8 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -104,7 +104,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let list = run(&mut s, "brush.libraries", json!({})); let libs = list["libraries"].as_array().unwrap(); let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); - assert_eq!(ids, ["art-ribbons", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); + assert_eq!(ids, ["art-ribbons", "ink-and-wash", "calligraphic-nibs", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams"]); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); assert_eq!( @@ -117,20 +117,22 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { trails["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Speed Line", "Dash Trail", "Burst Ray", "Zigzag"] ); - assert_eq!(libs[4]["count"].as_u64(), Some(4)); + assert_eq!(libs[5]["count"].as_u64(), Some(4)); let beams = run(&mut s, "brush.library", json!({"library": "energy-beams"})); assert_eq!( beams["brushes"].as_array().unwrap().iter().map(|b| b["name"].as_str().unwrap()).collect::>(), ["Laser Bolt", "Plasma Arc", "Charge Wave", "Spark Trail"] ); - assert_eq!(libs[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["count"].as_u64(), Some(4)); run(&mut s, "file.new", json!({"width": 200, "height": 200})); let id = run(&mut s, "shape.rectangle", json!({"x": 10, "y": 20, "width": 40, "height": 8}))["id"].as_u64().unwrap(); let added = run(&mut s, "brush.addFromLibrary", json!({"library": "energy-beams", "name": "Laser Bolt", "apply": true, "ids": [id]})); assert_eq!(strs(&added["added"]), ["Laser Bolt"]); - // One kind of brush per library, every brush drawing something. + // One kind of brush per library (Ink and Wash: art brushes and a calligraphic one), every + // brush drawing something. for (id, kind) in [ ("art-ribbons", "art"), + ("ink-and-wash", "art"), ("calligraphic-nibs", "calligraphic"), ("pattern-borders", "pattern"), ("scatter-shapes", "scatter"), @@ -140,6 +142,7 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let (_, lib) = cmd::brushlib::library(&s, id).unwrap(); assert!(lib.len() >= 4, "{id}"); for b in &lib.brushes { + let kind = if b.name == "Brush Pen" { "calligraphic" } else { kind }; assert_eq!(b.kind.type_id(), kind, "{}", b.name); let mut line = vectorcraft_geom::BezPath::new(); line.move_to((0.0, 0.0)); @@ -165,6 +168,153 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { assert!(s.execute("brush.library", &json!({"library": "nope"})).is_err()); } +/// Every path of brush art `n`: (anchors, opacity). +fn art_paths(n: &vectorcraft_doc::Node) -> Vec<(usize, f32)> { + let mut out = vec![]; + n.walk(&mut |c| { + if let Some(p) = c.path_data() { + out.push((p.anchor_count(), c.opacity)); + } + }); + out +} + +/// The Ink and Wash library, made once. +fn inks(s: &Session) -> std::sync::Arc { + cmd::brushlib::library(s, "ink-and-wash").unwrap().1 +} + +/// A straight line from the origin to (`x`, 0). +fn line_to(x: f64) -> vectorcraft_geom::BezPath { + let mut line = vectorcraft_geom::BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((x, 0.0)); + line +} + +fn black_stroke() -> vectorcraft_doc::StrokeLayer { + vectorcraft_doc::StrokeLayer::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), 1.0) +} + +/// Ink and Wash: a tapered ink art brush, a brush pen whose size follows the pen pressure and two +/// translucent washes, with names of their own and a few paths of art each. +#[test] +fn the_ink_and_wash_library_has_tapered_ink_a_brush_pen_and_two_washes() { + let mut s = session(); + let lib = run(&mut s, "brush.library", json!({"library": "ink-and-wash"})); + assert_eq!((lib["name"].as_str(), lib["category"].as_str()), (Some("Ink and Wash"), Some("builtIn"))); + let listed: Vec<(&str, &str)> = + lib["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect(); + assert_eq!(listed, [("Tapered Ink", "art"), ("Brush Pen", "calligraphic"), ("Soft Wash", "art"), ("Pooling Wash", "art")]); + assert_eq!(run(&mut s, "brush.library", json!({"library": "Ink and Wash"}))["id"], "ink-and-wash"); + let inks = inks(&s); + // Names no other built-in library uses. + for b in cmd::brushlib::BUILTIN.iter().filter(|b| b.id != "ink-and-wash") { + let (_, other) = cmd::brushlib::library(&s, b.id).unwrap(); + assert!(inks.brushes.iter().all(|x| other.brush(&x.name).is_none()), "{}", b.id); + } + // The art: within its 100 pt length, a few paths each, opaque ink and translucent washes. + let mut anchors = 0; + for name in ["Tapered Ink", "Soft Wash", "Pooling Wash"] { + let BrushKind::Art(a) = &inks.brush(name).unwrap().kind else { panic!("{name} is not an art brush") }; + assert_eq!((a.scale, a.colorization), (vectorcraft_brush::ArtScale::Stretch, vectorcraft_brush::Colorization::Tints), "{name}"); + let b = a.art.geometric_bounds().unwrap(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{name}"); + assert!(b.x0 >= 0.0 && b.x1 <= 100.0 && b.x1 - b.x0 > 99.0, "{name}: {b:?}"); + assert!(b.y0 >= -8.0 && b.y1 <= 8.0 && b.y1 - b.y0 > 5.0, "{name}: {b:?}"); + let paths = art_paths(&a.art); + let count: usize = paths.iter().map(|p| p.0).sum(); + assert!(paths.len() <= 80 && count <= 500, "{name}: {} paths, {count} anchors", paths.len()); + anchors += count; + let opacities: Vec = paths.iter().map(|p| p.1).collect(); + if name == "Tapered Ink" { + assert_eq!(opacities, [1.0], "opaque ink"); + } else { + assert!(paths.len() >= 3, "{name}: a wash of several layers"); + assert!(opacities.iter().all(|o| (0.15..=0.55).contains(o)), "{name}: {opacities:?}"); + } + } + assert!(anchors <= 1200, "{anchors} anchors"); + let BrushKind::Calligraphic(pen) = &inks.brush("Brush Pen").unwrap().kind else { panic!("Brush Pen is not calligraphic") }; + use vectorcraft_brush::Variation::{Fixed, Pressure}; + assert_eq!((pen.size, pen.modes(), pen.variation), (5.0, [Fixed, Fixed, Pressure], [0.0, 0.0, 2.5]), "no random variation"); +} + +/// Every Ink and Wash brush draws along straight, curved, short and tight paths within finite +/// bounds, and Tapered Ink is broad near its start and fine at its end. +#[test] +fn ink_and_wash_brushes_draw_along_straight_curved_short_and_tight_paths() { + let inks = inks(&Session::new()); + let mut curve = vectorcraft_geom::BezPath::new(); + curve.move_to((0.0, 0.0)); + curve.curve_to((60.0, -40.0), (140.0, 40.0), (200.0, 0.0)); + let tight = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(0.0, 0.0, 6.0, 6.0)).to_bezpath(); + let paths = [line_to(200.0), line_to(3.0), curve, tight]; + for b in &inks.brushes { + for line in &paths { + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, b, line, &black_stroke()); + assert!(!pieces.is_empty(), "{} draws nothing", b.name); + let bounds: Vec<_> = pieces.iter().map(|p| p.geometric_bounds()).collect(); + assert!(bounds.iter().all(|r| r.is_some_and(|r| [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()))), "{}", b.name); + } + } + let pieces = vectorcraft_brush::stroke_pieces(&inks.doc, inks.brush("Tapered Ink").unwrap(), &line_to(100.0), &black_stroke()); + let widest = |from: f64, to: f64| { + pieces + .iter() + .filter_map(|n| n.path_data()) + .flat_map(|p| p.anchors().map(|(_, _, a)| a.p).collect::>()) + .filter(|p| p.x >= from && p.x <= to) + .map(|p| p.y.abs()) + .fold(0.0, f64::max) + }; + assert!(widest(15.0, 30.0) > 4.0 * widest(95.0, 100.0), "broad start, fine tip"); +} + +/// Ink and Wash brushes are added and applied in one step, undone and redone, not added twice, +/// and kept with the brushed stroke by a saved and reopened document. +#[test] +fn ink_and_wash_brushes_add_apply_undo_and_survive_a_saved_document() { + let mut s = session(); + let inks = inks(&s); + let path = run(&mut s, "shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60}))["id"].as_u64().unwrap(); + let (defaults, before) = (brushes(&s), undo_len(&s)); + let names = ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]; + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash", "name": "Soft Wash", "apply": true, "ids": [path]})); + assert_eq!((strs(&r["added"]), r["applied"].clone(), undo_len(&s)), (vec!["Soft Wash"], json!([path]), before + 1)); + let stroke_brush = |s: &Session| doc(s).node(NodeId(path)).unwrap().appearance.stroke().and_then(|st| st.brush.clone()); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + run(&mut s, "edit.undo", json!({})); + assert_eq!((brushes(&s), stroke_brush(&s)), (defaults.clone(), None)); + run(&mut s, "edit.redo", json!({})); + assert_eq!(stroke_brush(&s).as_deref(), Some("Soft Wash")); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!( + (strs(&r["names"]), strs(&r["existing"]), strs(&r["added"])), + (names.to_vec(), vec!["Soft Wash"], vec!["Tapered Ink", "Brush Pen", "Pooling Wash"]) + ); + let before = undo_len(&s); + let r = run(&mut s, "brush.addFromLibrary", json!({"library": "ink-and-wash"})); + assert_eq!((strs(&r["existing"]), undo_len(&s)), (names.to_vec(), before)); + assert_eq!(brushes(&s).len(), defaults.len() + 4); + for n in names { + assert_eq!(&brush(&s, n), inks.brush(n).unwrap(), "{n}"); + } + + // Saved and opened again, the document keeps the definitions and the brushed stroke. + let b64 = run(&mut s, "document.serialize", json!({}))["dataBase64"].as_str().unwrap().to_string(); + let reopened = vectorcraft_format::load(&vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + for n in names { + assert_eq!(vectorcraft_brush::find(&reopened, n).as_ref(), inks.brush(n), "{n}"); + } + let n = reopened.node(NodeId(path)).unwrap(); + assert_eq!(n.appearance.stroke().and_then(|st| st.brush.clone()).as_deref(), Some("Soft Wash")); + assert_eq!( + vectorcraft_brush::node_pieces(&reopened, n).map(|p| p.len()), + vectorcraft_brush::node_pieces(doc(&s), doc(&s).node(NodeId(path)).unwrap()).map(|p| p.len()) + ); +} + #[test] fn a_saved_library_reopens_and_its_brushes_join_another_document_in_one_step() { let dir = folder("user"); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6b770ec1bc..312df6669d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6025,6 +6025,11 @@ axis Y Axis Y ос User Brush Library 9 Потребителска библиотека с четки 9 User Brush Library 10 Потребителска библиотека с четки 10 Art Ribbons Арт ленти + Ink and Wash Туш и лавиране + Tapered Ink Изтъняващ туш + Brush Pen Писалка-четка + Soft Wash Меко лавиране + Pooling Wash Лавиране с ръб Zigzag Зигзаг Burst Ray Лъч на удар Dash Trail Пунктирна следа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index f6029f08ef..f3186f3ad5 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6075,6 +6075,11 @@ axis Y Axis Y osa User Brush Library 9 Korisnička biblioteka četkica 9 User Brush Library 10 Korisnička biblioteka četkica 10 Art Ribbons Umjetničke trake + Ink and Wash Tuš i lavir + Tapered Ink Suženi tuš + Brush Pen Kistasti flomaster + Soft Wash Meki lavir + Pooling Wash Lavir s rubom Zigzag Cik-cak Burst Ray Zraka udara Dash Trail Isprekidani trag diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index cf0b677271..1ceeea242e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -363,6 +363,11 @@ antiAlias Sharp Nítid Art Brush Options Opcions del pinzell artístic Art Optimized Optimitzat per a l’art Art Ribbons Cintes d’art + Ink and Wash Tinta i aiguada + Tapered Ink Tinta afuada + Brush Pen Retolador de pinzell + Soft Wash Aiguada suau + Pooling Wash Aiguada amb vora Zigzag Zigzag Burst Ray Raig d’impacte Dash Trail Estela discontínua diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 9125a4f05f..a1fab26f85 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6276,6 +6276,11 @@ axis Y Axis osa Y User Brush Library 9 Uživatelská knihovna štětců 9 User Brush Library 10 Uživatelská knihovna štětců 10 Art Ribbons Obrazové stuhy + Ink and Wash Tuš a lavírování + Tapered Ink Zužující se tuš + Brush Pen Štětcový fix + Soft Wash Jemná lavírka + Pooling Wash Lavírka s okrajem Zigzag Cikcak Burst Ray Paprsek nárazu Dash Trail Čárkovaná stopa diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 00b46b0f22..83d85cc29a 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6040,6 +6040,11 @@ axis Y Axis Y-akse User Brush Library 9 Brugerpenselbibliotek 9 User Brush Library 10 Brugerpenselbibliotek 10 Art Ribbons Kunstbånd + Ink and Wash Blæk og lavering + Tapered Ink Tilspidset blæk + Brush Pen Penselpen + Soft Wash Blød lavering + Pooling Wash Lavering med rand Zigzag Zigzag Burst Ray Burststråle Dash Trail Streget spor diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec8d91ad1c..5f395e36c2 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5867,6 +5867,11 @@ axis Y Axis Y-Achse User Brush Library 9 Benutzer-Pinsel-Bibliothek 9 User Brush Library 10 Benutzer-Pinsel-Bibliothek 10 Art Ribbons Bildbänder + Ink and Wash Tusche und Lavur + Tapered Ink Spitz zulaufende Tusche + Brush Pen Pinselstift + Soft Wash Weiche Lavur + Pooling Wash Lavur mit Farbrand Zigzag Zickzack Burst Ray Stoßstrahl Dash Trail Strichelspur diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 17527f5871..a122d49959 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6227,6 +6227,11 @@ axis Y Axis Άξονας Y User Brush Library 9 Βιβλιοθήκη πινέλων χρήστη 9 User Brush Library 10 Βιβλιοθήκη πινέλων χρήστη 10 Art Ribbons Γραφικές κορδέλες + Ink and Wash Μελάνι και ακουαρέλα + Tapered Ink Λεπτυνόμενο μελάνι + Brush Pen Πένα πινέλου + Soft Wash Απαλή ακουαρέλα + Pooling Wash Ακουαρέλα με περίγραμμα Zigzag Ζιγκζάγκ Burst Ray Ακτίνα κρούσης Dash Trail Διακεκομμένο ίχνος diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f4ea5ecf80..5e83437a9a 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6218,6 +6218,11 @@ axis Y Axis Eje Y User Brush Library 9 Biblioteca de pinceles del usuario 9 User Brush Library 10 Biblioteca de pinceles del usuario 10 Art Ribbons Cintas de ilustración + Ink and Wash Tinta y aguada + Tapered Ink Tinta afilada + Brush Pen Rotulador de pincel + Soft Wash Aguada suave + Pooling Wash Aguada con borde Zigzag Zigzag Burst Ray Rayo de impacto Dash Trail Estela discontinua diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b7a34058f4..0dd4c21738 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6306,6 +6306,11 @@ axis Y Axis Y-akseli User Brush Library 9 Käyttäjän sivellinkirjasto 9 User Brush Library 10 Käyttäjän sivellinkirjasto 10 Art Ribbons Kuvitusnauhat + Ink and Wash Muste ja laveeraus + Tapered Ink Suippeneva muste + Brush Pen Pensselikynä + Soft Wash Pehmeä laveeraus + Pooling Wash Reunustettu laveeraus Zigzag Siksak Burst Ray Iskusäde Dash Trail Katkoviiva diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 36bf674ef2..a9c4c80bcc 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6271,6 +6271,11 @@ axis Y Axis Axe Y User Brush Library 9 Bibliothèque de formes utilisateur 9 User Brush Library 10 Bibliothèque de formes utilisateur 10 Art Ribbons Rubans d'illustration + Ink and Wash Encre et lavis + Tapered Ink Encre effilée + Brush Pen Feutre pinceau + Soft Wash Lavis doux + Pooling Wash Lavis à auréole Zigzag Zigzag Burst Ray Rayon d’impact Dash Trail Traînée tiretée diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 7dbeabac1e..43818a5673 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -424,6 +424,11 @@ antiAlias Sharp शार्प Art Brush Options आर्ट ब्रश ऑप्शन्स Art Optimized आर्टवर्क के लिए अनुकूलित Art Ribbons आर्ट रिबन + Ink and Wash स्याही और वॉश + Tapered Ink पतली होती स्याही + Brush Pen ब्रश पेन + Soft Wash सॉफ्ट वॉश + Pooling Wash पूलिंग वॉश Zigzag ज़िगज़ैग Burst Ray बर्स्ट रे Dash Trail डैश ट्रेल diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c87d42e6dc..84ffbba64f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6259,6 +6259,11 @@ axis Y Axis Y tengely User Brush Library 9 Felhasználói ecsetkönyvtár 9 User Brush Library 10 Felhasználói ecsetkönyvtár 10 Art Ribbons Grafikus szalagok + Ink and Wash Tus és lavírozás + Tapered Ink Elvékonyodó tus + Brush Pen Ecsetfilc + Soft Wash Lágy lavírozás + Pooling Wash Szegélyes lavírozás Zigzag Cikkcakk Burst Ray Ütéssugár Dash Trail Szaggatott nyom diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 9925f7b0d8..0b110cbaf9 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6040,6 +6040,11 @@ axis Y Axis Sumbu Y User Brush Library 9 Pustaka kuas pengguna 9 User Brush Library 10 Pustaka kuas pengguna 10 Art Ribbons Pita seni + Ink and Wash Tinta dan sapuan + Tapered Ink Tinta meruncing + Brush Pen Pena kuas + Soft Wash Sapuan lembut + Pooling Wash Sapuan menggenang Zigzag Zigzag Burst Ray Sinar ledakan Dash Trail Jejak putus-putus diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 36c8820296..88151b67d2 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5901,6 +5901,11 @@ axis Y Axis Asse Y User Brush Library 9 Libreria pennelli utente 9 User Brush Library 10 Libreria pennelli utente 10 Art Ribbons Nastri grafici + Ink and Wash Inchiostro e acquerellato + Tapered Ink Inchiostro affusolato + Brush Pen Pennarello a pennello + Soft Wash Velatura morbida + Pooling Wash Velatura con bordo Zigzag Zigzag Burst Ray Raggio d’impatto Dash Trail Scia tratteggiata diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 6dcabf27e8..8fa2f9751d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5966,6 +5966,11 @@ axis Y Axis Y 軸 User Brush Library 9 ユーザーブラシライブラリ 9 User Brush Library 10 ユーザーブラシライブラリ 10 Art Ribbons アートリボン + Ink and Wash 墨と淡彩 + Tapered Ink 先細りの墨 + Brush Pen 筆ペン + Soft Wash 柔らかい淡彩 + Pooling Wash たまりのある淡彩 Zigzag ジグザグ Burst Ray バーストレイ Dash Trail ダッシュトレイル diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 23a18557fa..24a8023c6d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6227,6 +6227,11 @@ axis Y Axis Y ღერძი User Brush Library 9 მომხმარებლის ფუნჯების ბიბლიოთეკა 9 User Brush Library 10 მომხმარებლის ფუნჯების ბიბლიოთეკა 10 Art Ribbons ნახატის ლენტები + Ink and Wash მელანი და ლავირება + Tapered Ink წვეტიანი მელანი + Brush Pen ფუნჯ-კალამი + Soft Wash რბილი ლავირება + Pooling Wash კიდიანი ლავირება Zigzag ზიგზაგი Burst Ray დარტყმის სხივი Dash Trail წყვეტილი კვალი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 5366483cd9..62de80acae 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5862,6 +5862,11 @@ axis Y Axis Y축 User Brush Library 9 사용자 브러시 라이브러리 9 User Brush Library 10 사용자 브러시 라이브러리 10 Art Ribbons 아트 리본 + Ink and Wash 먹과 담채 + Tapered Ink 가늘어지는 먹선 + Brush Pen 붓펜 + Soft Wash 부드러운 담채 + Pooling Wash 고임 담채 Zigzag 지그재그 Burst Ray 버스트 레이 Dash Trail 대시 트레일 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 20ea6ac94d..929424839d 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6091,6 +6091,11 @@ axis Y Axis Y-as User Brush Library 9 Penseelbibliotheek van gebruiker 9 User Brush Library 10 Penseelbibliotheek van gebruiker 10 Art Ribbons Illustratielinten + Ink and Wash Inkt en wassing + Tapered Ink Taps toelopende inkt + Brush Pen Penseelstift + Soft Wash Zachte wassing + Pooling Wash Wassing met rand Zigzag Zigzag Burst Ray Inslagstraal Dash Trail Streepspoor diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 8d72f58fa4..da2b8be141 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6130,6 +6130,11 @@ axis Y Axis Oś Y User Brush Library 9 Biblioteka pędzli użytkownika 9 User Brush Library 10 Biblioteka pędzli użytkownika 10 Art Ribbons Wstęgi artystyczne + Ink and Wash Tusz i laworowanie + Tapered Ink Zwężający się tusz + Brush Pen Pisak pędzelkowy + Soft Wash Miękkie laworowanie + Pooling Wash Laworowanie z obwódką Zigzag Zygzak Burst Ray Promień uderzenia Dash Trail Ślad kreskowany diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index eb2c29947a..07fa9bf299 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6191,6 +6191,11 @@ axis Y Axis Eixo Y User Brush Library 9 Biblioteca de Pincéis do Usuário 9 User Brush Library 10 Biblioteca de Pincéis do Usuário 10 Art Ribbons Fitas de Arte + Ink and Wash Nanquim e Aguada + Tapered Ink Nanquim Afilado + Brush Pen Caneta Pincel + Soft Wash Aguada Suave + Pooling Wash Aguada com Borda Zigzag Zigue-zague Burst Ray Raio de impacto Dash Trail Rastro tracejado diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 533192c53a..58a4a44947 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5887,6 +5887,11 @@ axis Y Axis Ось Y User Brush Library 9 Пользовательская библиотека кистей 9 User Brush Library 10 Пользовательская библиотека кистей 10 Art Ribbons Художественные ленты + Ink and Wash Тушь и размывка + Tapered Ink Сужающаяся тушь + Brush Pen Ручка-кисть + Soft Wash Мягкая размывка + Pooling Wash Размывка с ободком Zigzag Зигзаг Burst Ray Ударный луч Dash Trail Пунктирный след diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 558238e60e..e9d9487dda 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6294,6 +6294,11 @@ axis Y Axis Y-axel User Brush Library 9 Användarbibliotek för penslar 9 User Brush Library 10 Användarbibliotek för penslar 10 Art Ribbons Konstband + Ink and Wash Tusch och lavering + Tapered Ink Avsmalnande tusch + Brush Pen Penselpenna + Soft Wash Mjuk lavering + Pooling Wash Lavering med kant Zigzag Sicksack Burst Ray Stötstråle Dash Trail Streckat spår diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f88726b3fc..89c8552720 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6232,6 +6232,11 @@ axis Y Axis Y ekseni User Brush Library 9 Kullanıcı Fırça Kitaplığı 9 User Brush Library 10 Kullanıcı Fırça Kitaplığı 10 Art Ribbons Sanat Şeritleri + Ink and Wash Mürekkep ve Lavi + Tapered Ink Sivrilen Mürekkep + Brush Pen Fırça Kalem + Soft Wash Yumuşak Lavi + Pooling Wash Kenarlı Lavi Zigzag Zigzag Burst Ray Darbe ışını Dash Trail Kesik iz diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8038529844..2c6f0ffec0 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5881,6 +5881,11 @@ axis Y Axis Вісь Y User Brush Library 9 Користувацька бібліотека пензлів 9 User Brush Library 10 Користувацька бібліотека пензлів 10 Art Ribbons Художні стрічки + Ink and Wash Туш і розмивка + Tapered Ink Звужена туш + Brush Pen Пензлева ручка + Soft Wash М'яка розмивка + Pooling Wash Розмивка з облямівкою Zigzag Зигзаг Burst Ray Ударний промінь Dash Trail Пунктирний слід diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6178748b4f..e5de265edc 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5882,6 +5882,11 @@ axis Y Axis Trục Y User Brush Library 9 Thư viện cọ người dùng 9 User Brush Library 10 Thư viện cọ người dùng 10 Art Ribbons Dải băng nghệ thuật + Ink and Wash Mực và màu loang + Tapered Ink Mực vuốt nhọn + Brush Pen Bút lông + Soft Wash Màu loang mềm + Pooling Wash Màu loang đọng viền Zigzag Zigzag Burst Ray Tia va chạm Dash Trail Vệt đứt đoạn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 20ae4298b6..9893f54765 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6114,6 +6114,11 @@ axis Y Axis Y 轴 User Brush Library 9 用户画笔库 9 User Brush Library 10 用户画笔库 10 Art Ribbons 艺术丝带 + Ink and Wash 水墨淡彩 + Tapered Ink 渐细墨线 + Brush Pen 软头笔 + Soft Wash 柔和淡彩 + Pooling Wash 积色淡彩 Zigzag 锯齿 Burst Ray 冲击射线 Dash Trail 虚线轨迹 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index ee57010551..f42668065f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5919,6 +5919,11 @@ axis Y Axis Y 軸 User Brush Library 9 使用者筆刷資料庫 9 User Brush Library 10 使用者筆刷資料庫 10 Art Ribbons 藝術緞帶 + Ink and Wash 水墨淡彩 + Tapered Ink 漸細墨線 + Brush Pen 軟頭筆 + Soft Wash 柔和淡彩 + Pooling Wash 積色淡彩 Zigzag 鋸齒 Burst Ray 衝擊射線 Dash Trail 虛線軌跡 diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 6918e9118f..a937100076 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -120,6 +120,26 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() { assert!(last_open(&app.ui).is_none()); } +#[test] +fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { + let mut app = app(); + let ctx = context(); + let r = app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); + assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("ink-and-wash"), Some(4))); + let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; + let rows = BrushLibraries::rows(&lib, ""); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]); + let path = app.run("shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60})).unwrap()["id"].as_u64().unwrap(); + let (undo, brushes) = (undo_len(&app), count(&app)); + click(&mut app, &ctx, "Pooling Wash", Modifiers::NONE, 0.0); + assert_eq!((count(&app), undo_len(&app)), (brushes + 1, undo + 1), "one brush, one undo step"); + let n = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(path)).unwrap().clone(); + assert_eq!(n.appearance.stroke().and_then(|s| s.brush.clone()).as_deref(), Some("Pooling Wash")); + click(&mut app, &ctx, "Brush Pen", Modifiers::NONE, 2.0); + assert!(has(&app, "Brush Pen")); + assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen"); +} + #[test] fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let mut app = app(); @@ -131,6 +151,7 @@ fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { opened, [ json!("art-ribbons"), + json!("ink-and-wash"), json!("calligraphic-nibs"), json!("pattern-borders"), json!("scatter-shapes"), diff --git a/docs/gaps.md b/docs/gaps.md index 3a72e15433..ea11f8273a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -631,7 +631,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi libraries ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html)): Window › Brush Libraries and the Brushes panel's Brush Libraries Menu open each library in a library panel of - its own (six original libraries generated in code: Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter + its own (seven original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails and Energy Beams; User Defined; Other Library… reads the brushes of any document VectorCraft opens); a click adds a brush to the document's Brushes with the patterns, symbols and other resources its art uses, as one undo @@ -651,7 +651,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi with Poison Tint, Freeze Edge, Burn Aura, Stun Pulse, Stealth Fade and Shield Buff, and Textures with Fine Grain, Canvas Weave, Burlap Sack, Cracked Glaze, Mosaic Floor, Quilted Squares, Leaded Glass and Damp Paper, painted with the texture effects; User Defined; Other Library…; `graphicStyle.libraries`, `graphicStyle.addFromLibrary`). -- **Missing:** more built-in brush libraries beyond the six shipped (and further graphic-style libraries beyond the nine shipped, and further symbol libraries beyond Basic +- **Missing:** more built-in brush libraries beyond the seven shipped (and further graphic-style libraries beyond the nine shipped, and further symbol libraries beyond Basic Shapes, UI Frames, Map Markers, Inventory Icons and Skill Icons; original, generated in code, never Adobe's); dynamic symbols' `.ai` read and write; Global Edit of groups holding type or images, rotated copies as matches and highlighting matches before the start; 9-slice guides outside the symbol's art (a dragged guide stops at the art's edge). diff --git a/docs/mcp.md b/docs/mcp.md index b9c23ad2d1..d9f29df7d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3429,7 +3429,7 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: six generated in code (Art Ribbons, Calligraphic Nibs, Pattern +Brush libraries are read-only sets of brushes: seven generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams), the `.vectorcraft` files in the user library folder of the desktop app (User Defined) and the brushes of any document loaded with `brush.loadLibrary` (a document without brushes of its own has the default ones). `brush.libraries` lists them (`id`, `name`, `category`: `builtIn`, `user` or `loaded`, `count`) and diff --git a/docs/roadmap.md b/docs/roadmap.md index cf782106a1..7f859d5b8a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -149,7 +149,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Anchor Point spine conversion/handle editing and selection priority (#2544), with #2425 Shift reshape preserved; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder, Color Adjustments and Document Raster Effects Settings; PDF export warns when the pixel cap lowers raster-effect resolution; Artistic › Colored Pencil, Cutout, Dry Brush, Film Grain, Fresco, Neon Glow, Paint Daubs, Palette Knife, Poster Edges, Rough Pastels, Smudge Stick, Sponge, Underpainting and Watercolor and Sketch › Halftone Pattern, Bas Relief, Charcoal, Conté Crayon, Photocopy, Stamp, Torn Edges and Water Paper done (57/57 Photoshop filters); Revolve with automatic smooth vector shading, default-on circular caps and continuous rims, Extrude & Bevel, Inflate and Rotate with one to ten colored directional lights and four original lighting presets; SVG Filters, remaining raster effects and 3D materials and shadows pending | 81–130 | -| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, six generated libraries, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | +| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG ``/PDF export) and live Repeat (radial/grid/mirror) done; interactive selected mirror axis implemented (focused and native checks passed; full CI blocked by an unrelated text test); symbol libraries done (Window › Symbol Libraries, five generated libraries (Basic Shapes, UI Frames, Map Markers, Inventory Icons, Skill Icons), User Defined, Other Library…, Save Symbols); brush libraries done (Window › Brush Libraries, seven generated libraries including Ink and Wash, User Defined, Other Library…, Save Brushes); symbol editing shows draggable 9-slice guides (#5280), with guides outside the artwork still unsupported; brushes/symbols in progress | 23–37 | | M11 | Artboards & views (selected-set deletion shared by Artboards menu, trash and Properties, retaining artboard 1 when all are selected; Artboard deletion keys keep the first of an all-selected set and protect artwork; history prunes invalid indices while preserving valid multiselection (history tests and full workspace CI pass); click-to-fit objects with the Artboard tool (#1227), artboard escape routing and document setup modal handoff done; artboard panel/tool with Move Artwork for X/Y fields and Artboard Options, Trim View, middle-button pan, print tiling and the status bar's artboard navigator with synchronized Artboard tool selection done; Window › Arrange › Tile and Consolidate All Windows done; multiple windows, presentation polish) (cached canvas reprojection preserves scale during resize, rotation and DPI changes; regression-tested and contributor-confirmed 2026-10-19; estimates unchanged) (cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission; regression-tested; estimates unchanged); status-bar pointer coordinates follow the active ruler origin and General units | 🟡; Artboard Options shares existing presets and orientation controls and can fit artwork without moving it; artwork fitting includes all drawn document art across artboards and off-board; artwork bounds are cached by active document and revision, and selection uses the shared bounds cache across artboards | 15–25 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (junction-returning pieces retain their positioned boundaries; a simpler contour walk with identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments; large images trace with a warning; Auto Color keeps rare distinct colours; quick presets trace at once and the Image Trace button opens the adjustment controls; Properties reopens them; tiny fitted contours retain islands and holes; Create Strokes: exact crop EDT, Guo–Hall thinning and maximal-disc graph simplification; 15 presets, the 14 documented ones each with an enhanced version; Ignore Color, Shapes, Auto Grouping and Gradients options; measured in `image-trace-quality.md`; closed outlines retain their interior after straightening; the Flat Logo mode places edges to a fraction of a pixel, restores sharp corners, snaps lines and circles, and refuses gradients; live trace result bounds and selection highlights exclude the retained bitmap except in source-image views), Recolor Artwork, colour management (ICC, soft proofing, separations preview), Graphs (all 9 tools, Graph Data/Type, group-selected series colours persist through regenerate/save/open) done; Graph Design and Marker designs, Transpose and Switch x/y, `\|` label line breaks, the pie options, drop shadows, the legend across the top, overlapping widths, First Row/Column in Front, filled lines, kept label formatting and the Graph Data worksheet done; graph Column designs pending; Vector Halftone Preview keeps the selection box without drawing dot selection marks (#6369); Live Paint Bucket fills faces crossed in one drag (#6373) | 6–10 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 MCP `add_text` exposes existing vertical point, area and path type; `script.run` and File › Scripts › Other Script… run JavaScript files, and Window › Script Console shows their output; the script document model and batch work remain | 18–27 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9c58bcaa8c..b3d65f9ee2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -220,7 +220,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Drawing tools (Pen, Curvature, Pencil, shapes, Shaper) | 7 | 85% | Shaper construction widget, Pencil/Pen nuances, Blob Brush options, Touch Type, Dimension tool; see [ui-parity.md](ui-parity.md) | 8–13 | estimated; Curvature point display regression-tested (#1236, in part); Polygon/Star exact-size count persistence regression-covered; Live Corners' Relative rounding done (#3091), score unchanged; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123), score and hours unchanged | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 80% | Shape Builder on the reporter's file (#937: art past 4,096 segments is cut by lines again; unconfirmed on the file itself); gap detection shares Live Paint’s finder, with a 72 pt custom cap and the existing options; Live Paint Gap Options | 4–8 | estimated; Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog (lowered from 86%: open issues); compound attributes regression-tested (#1197); single-result source names survive SVG export/import (#1293), with [SVG ID semantics](https://www.w3.org/TR/SVG2/struct.html#IDAttribute) and original-shape tests; Simplify component selection and preview regression-tested (#1272); Simplify of a Direct Selection part of a path regression-tested (`tests/simplify_part.rs`); Divide open-cutting-path geometry and appearance regression-tested (#1307); the Shape Builder's edge-source grid is bounded in memory and files a segment once per cell (`planar.rs` test `rect_index_holds_a_bounded_number_of_references`); live compound shapes (Make, Alt-click, member Shape Modes, Release, Expand, saved and exported) regression-tested (#4522); Live Paint Bucket face drags, including narrow faces and one Undo, regression-tested (#6373) | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | -| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (six generated libraries including Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | +| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (seven generated libraries including Ink and Wash, Game Trails and Energy Beams, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | | 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | @@ -489,7 +489,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Drawing tools | 7 | 86% | rough-square Shaper recognition and orientation steps corrected; editable Shaper Groups landed; Pencil: Alt key toggles to Smooth Tool (#2001); remaining: Shaper construction widget/gesture refinements, pencil and the remaining pen modifier nuances, Blob Brush Merge Only with Selection, Touch Type, Live Corners' Relative rounding | 7–12 | | Path operations, Pathfinder, Shape Builder, Live Paint | 5 | 86% | Live Paint gap options, Shape Builder gap detection, options (Tool Options dialog, clicking a stroke in merge mode) and Shift-drag marquee | 3–6 | | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 96% | freeform gradients in SVG/PDF and mixed spot/process gradients in SVG (exported as stops; PDF export keeps the spot inks in a DeviceN shading, except next to RGB or Lab stops; dithered fills and Perceptual gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve are written as images in process colors), pattern fills in Expand | 2–4; Eyedropper source reads regression-tested (#1251) | -| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (six generated in code including Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | +| Strokes, brushes, width profiles | 5 | 90% | more built-in brush libraries (seven generated in code including Ink and Wash, Game Trails and Energy Beams are done; Art brush Overlap and Pattern brush auto corners are done), pen pressure on macOS (the windowing layer reports none there), Stylus Wheel, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | | Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 | | Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 | | 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -541,7 +541,7 @@ since 2026-10-01. | **Interaction fidelity:** modifiers, cursors, small behaviours | ~30–40% | The dedicated pass hasn't started, and there has been no side-by-side session with Illustrator yet. A power user notices this first | | **Look & feel** | ~75–80% | Measured against Illustrator 2026 screenshots (2026-10-02); the panels added since haven't been re-measured | | **File interop** | ~85% | SVG/SVGZ, PDF and PDF-compatible `.ai` (and legacy version 7 `.ai` out, keeping layers, groups and type as text), EPS, DXF, EMF/WMF, raster formats, PSD export and PSD/PSB placement (merged image), Place and Links, Package, Print, clipboard flavours. Affinity documents (Affinity 1–3) open natively as editable art, checked against 51 pinned public files (33 current `.af`), with selected saved-thumbnail comparisons and every-board native/SVG/PDF/PSD checks for current files; Affinity export is missing and effects, adjustments and brushes don't import. Affinity isn't an Illustrator feature, so this doesn't raise the parity score. The editing copy inside an Illustrator EPS or `.ai` is read for its structure: layers, groups, names, hidden objects and type, colours, gradients, transparency, images and every artboard (see Out of scope for what it doesn't read yet); DWG has no open spec | -| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries six (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | +| **Bundled content:** brush, symbol, style and swatch libraries | ~46% | Ours are original and generated in code, and far fewer than Illustrator ships; symbol libraries have five (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons, Skill Icons), graphic style libraries eight (including Game HUD and Status Effects) and brush libraries seven (Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) beside User Defined and Other Library… | | **Performance** | unverified | Multithreaded, off-thread rendering and caches are in place. The last budget run (2026-10-01, loaded machine) measured 290 ms for a 50k-path fit against a 16 ms budget. Re-run `vectorcraft-cli perf` on an idle machine | | **Robustness** | good, new | ~2,840 tests, property tests, no panics in shipped code (lints, the `guard` safety net, import fuzzing), Data Recovery. Missing: a corpus of real-world files, Windows/Linux/browser QA | | **Platforms and 1.0 polish** | ~60% | The macOS app and the web build work, and Finder opens every readable format with the macOS app (#295, #354); on macOS the menus are in the system menu bar, laid out as a Mac app's, with the in-window menus as a fallback (`--in-window-menus`; verified on Windows and by cross-compilation, not yet by hand on a Mac); hybrid-graphics laptops render on the power-saving GPU by default on Windows and macOS (#306), and Windows starts on Direct3D 12, falling back to OpenGL, and loads no Vulkan driver unless `WGPU_BACKEND` asks for it (#545: Vulkan flickered black on an Intel GPU, DX12 and OpenGL didn't; #806: a faulty Vulkan driver crashed the app before its window appeared; verified by unit tests and the reporter's runs with `WGPU_BACKEND`), and Linux on the GPU the desktop runs on, since a Wayland compositor may refuse frames from the other one; a window that fails on its GPU while starting up starts again on the next one instead of crashing (#502; the dual-GPU Wayland case is verified by unit tests and cross-compilation, not on such hardware); web drops land in the document they were dropped on (#359); desktop drops carry no pointer position (winit 0.30), so a dropped document opens as a tab of its own and a picture or text is placed in the middle of the view, while the web places anything dropped on the canvas and opens what is dropped off it, as Illustrator does (#472); a web page that loses its WebGPU device or WebGL context restarts its graphics with the same documents (#369); on Linux/Wayland the clipboard reads the compositor's own (data-control) and copied image files paste, but files can't be dropped on the window under native Wayland until eframe moves to winit 0.31 (#398; the Wayland side is verified by compilation only), so Preferences › Performance › Linux Display Server › X11 (XWayland) opens the window through XWayland, where drops work (#1151; verified by unit tests and cross-compilation, not on a Wayland desktop); on Linux the Paste menu checks the system clipboard on a background thread, so a clipboard owner that never answers no longer freezes the window, while Windows and macOS keep their quick in-line check (#510; the X11 hang verified by the contributor, here by headless tests); on KDE Plasma 6.3+ under Wayland, pens and pen displays reach the app only under XWayland (`WAYLAND_DISPLAY=`) or with KWin's `KWIN_WAYLAND_EMULATE_TABLET=1`, since winit 0.30 has no Wayland tablet input (#491, not verified on hardware); Windows/Linux packaging and accessibility are pending | @@ -845,7 +845,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Expand of symbol instances:** Object › Expand turns selected symbol instances (without effects, not in perspective, in groups or placed documents too) into their art as Break Link to Symbol places it, one level deep, as one undo step; the Expand dialog's Fill and Stroke reach that art (`object.expand`, `crates/engine/src/tests/expand_symbols.rs`; estimated, score unchanged). - **Reset Transformation** ([Transform symbols](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/transform-symbols.html), [Symbols panel options](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html)): the Symbols panel menu's Reset Transformation and the Control bar's Reset, shown while symbol instances are selected, give the selected instances their symbol's own scale again, without rotation or shear, as one undo step (`symbol.instance.resetTransform`); an instance in perspective is left as it is. VectorCraft's own rules: each instance keeps the center of its bounding box in place, reflection is removed too, and the menu item and the button are enabled only while a selected instance would change. Tests: `tests::brushsym::reset_transformation_restores_the_symbols_scale_and_keeps_the_center`, `reset_transformation_keeps_the_center_of_art_away_from_the_origin`, `reset_transformation_ignores_a_stored_size_that_is_not_positive`, `reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. + - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From bd82a598dd6863740b6ffe79207b5f283b70328c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:07:35 -0800 Subject: [PATCH 02/35] Blend: on-canvas widgets set a selected blend's color shift and easing With one whole blend selected, the Selection and Blend tools show two widgets at the halfway point of its spine. Dragging the upper widget along the spine sets the color shift (-100 to 100), and dragging the lower one sets the easing strength (0 to 100). The lower widget shows only when the steps follow a spine (a replaced spine or Align to Path), the rule the step placement uses; a blend made without a spine shows neither. When the boxes of three or more other visible objects hold a widget's spot, the widget moves out in 16 px steps, up to 144 px, and its line to the halfway point grows with it. While a widget is held, the arrow keys step its value by 1 (Shift: by 10), and Escape or a switch to another tool cancels the drag. A drag is one undo step; a click or a drag that ends where it started records none. - object.blend.setEasing sets easingType, ramp and colorShift on one blend, refuses a hidden or locked blend and easing on a blend without a spine, and records no undo step when nothing changes. - BlendSpec::eases_along_spine holds the rule the step placement and the widget share. - While a tool is busy, an arrow it claims arrives with the press's Shift and Alt; before, it always arrived plain. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/doc/src/blend.rs | 2 +- crates/doc/src/live/blend_spec.rs | 6 + crates/engine/src/cmd/live/blend.rs | 33 ++ crates/engine/src/cmd/live/commands.rs | 11 +- crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/tests/blendopts.rs | 131 +++++ crates/tools/src/blendwidget.rs | 536 ++++++++++++++++++ crates/tools/src/lib.rs | 1 + crates/tools/src/meshblend.rs | 59 +- crates/tools/src/select.rs | 46 +- crates/ui-egui/src/canvas.rs | 5 +- crates/ui-egui/src/canvas/overlays.rs | 20 + crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/shortcuts.rs | 14 +- crates/ui-egui/src/shortcuts/tests.rs | 32 ++ docs/mcp.md | 17 + docs/target-app-parity.md | 2 + 43 files changed, 930 insertions(+), 13 deletions(-) create mode 100644 crates/tools/src/blendwidget.rs diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 05968ced7e..c9628016a5 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1421,7 +1421,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; // Easing needs a spine: a blend made without one keeps its steps evenly spaced. - let eased = rail.is_some() && !spec.no_spine; + let eased = rail.is_some() && spec.eases_along_spine(); let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 56405e0d18..047e1ad7b5 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -119,6 +119,12 @@ impl BlendSpec { pub fn start(&self, i: usize) -> Option { self.starts.get(i).copied().flatten().map(|a| a as usize) } + + /// Whether [`Self::ease`] moves the steps: the blend has a spine ([`Self::no_spine`] is off) + /// and a replaced spine or Align to Path, the cases where the steps are placed along it. + pub fn eases_along_spine(&self) -> bool { + !self.no_spine && (self.spine.is_some() || self.orientation == BlendOrientation::AlignToPath) + } } /// The Blend panel's Create Spine setting: whether a new blend gets a spine. diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 609936ecf7..63ae4a3fb9 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -385,6 +385,39 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result { }) } +pub(super) fn blend_set_easing(s: &mut Session, p: &Value) -> Result { + const C: &str = "object.blend.setEasing"; + let id = match crate::cmd::object_param(p, "id", C)? { + Some(id) => id, + None => *selected_of(s, is_blend).first().ok_or_else(|| bad(C, "select a blend or give `id`"))?, + }; + let doc = &s.doc()?.doc; + let Some(NodeKind::Blend { children, spec }) = doc.node(id).map(|n| &n.kind) else { return Err(bad(C, "not a blend")) }; + if !doc.is_editable(id) { + return Err(bad(C, "the object is hidden or locked")); + } + let ease = ease_param(p, C, spec.ease)?; + let color_shift = percent_param(p, C, "colorShift", spec.color_shift, -100.0)?; + // Easing moves the steps along a spine, so it needs one with a length to run along. + let easing = ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null())); + let spined = spec.eases_along_spine() + && live::blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)).is_some_and(|sp| sp.length().is_finite() && sp.length() > 0.0); + if easing && !spined { + return Err(bad(C, "the blend has no spine")); + } + let changed = ease != spec.ease || color_shift != spec.color_shift; + if changed { + s.edit("Blend Options", |d, _| { + if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + spec.ease = ease; + spec.color_shift = color_shift; + } + Ok(()) + })?; + } + Ok(json!({ "id": id.0, "easingType": ease.kind.id(), "ramp": ease.ramp * 100.0, "colorShift": color_shift, "changed": changed })) +} + /// Spacing and orientation as `object.blend.options` takes them; the step count and distance /// not in use show their defaults. fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value { diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 5480f7ae58..4cbc5e130f 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -1,6 +1,6 @@ use super::blend::{ blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine, - blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, + blend_reverse_stack, blend_set_easing, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment, }; use super::common::{has_blend, has_envelope, has_mesh}; use super::envelope::{ @@ -49,6 +49,15 @@ pub fn specs() -> Vec { always, blend_create_spine ), + cmd!( + "object.blend.setEasing", + "Blend Easing", + [], + None, + "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path, on a blend not made without a spine); out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", + has_doc, + blend_set_easing + ), cmd!( "object.blend.expand", "Expand", diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index 5c03317fa9..a562b7e332 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -238,6 +238,7 @@ pub(super) const ALLOWED: &[&str] = &[ "object.blend.replaceSpine", "object.blend.reverseFrontToBack", "object.blend.reverseSpine", + "object.blend.setEasing", "object.blend.spine.addAnchor", "object.blend.spine.convertAnchor", "object.blend.spine.moveAnchor", diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 4b391c5714..a71e835108 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -405,6 +405,137 @@ fn a_document_with_bad_blend_easing_numbers_opens_clamped_while_commands_refuse( } } +/// `object.blend.setEasing` sets one blend's easing and color shift, records no undo step when +/// nothing changes, and refuses bad values, a blend whose steps follow no spine (easing only), a +/// hidden or locked blend and a target that is not a blend, leaving the blend and the history alone. +#[test] +fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3})).unwrap()); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let d0 = depth(&s); + // Align to Page along the straight lines between the keys: the steps follow no spine. + for bad in [json!({"ramp": 30}), json!({"easingType": "easeOut"})] { + let e = s.execute("object.blend.setEasing", &bad).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + } + assert_eq!(depth(&s), d0); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!(r, json!({"id": g.0, "easingType": "easeIn", "ramp": 0.0, "colorShift": 25.0, "changed": true})); + assert_eq!((spec(&s, g).1.color_shift, depth(&s)), (25.0, d0 + 1)); + let r = s.execute("object.blend.setEasing", &json!({"colorShift": 25})).unwrap(); + assert_eq!((&r["changed"], depth(&s)), (&json!(false), d0 + 1), "an unchanged value records no undo step"); + // Along the spine each of the four types keeps its kind through a ramp change and its undo. + s.execute("object.blend.options", &json!({"orientation": "path"})).unwrap(); + for kind in ["easeIn", "easeOut", "easeInOut", "easeOutIn"] { + s.execute("object.blend.setEasing", &json!({"easingType": kind, "ramp": 60})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"ramp": 20})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["colorShift"]), (&json!(kind), &json!(20.0), &json!(25.0))); + s.execute("edit.undo", &json!({})).unwrap(); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift), (kind, 0.6, 25.0)); + } + let before = spec(&s, g).1; + let d1 = depth(&s); + for bad in [ + json!({"ramp": 101}), + json!({"ramp": -1}), + json!({"ramp": "x"}), + json!({"colorShift": 1e308}), + json!({"colorShift": -101}), + json!({"easingType": "sideways"}), + json!({"id": "abc", "ramp": 10}), + json!({"id": a.0, "ramp": 10}), + ] { + assert!(s.execute("object.blend.setEasing", &bad).is_err(), "{bad}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before.clone(), d1), "{bad}"); + } + // An explicit id works with nothing selected; nothing selected and no id is an error. + s.execute("select.none", &json!({})).unwrap(); + assert!(s.execute("object.blend.setEasing", &json!({"ramp": 10})).is_err()); + s.execute("object.blend.setEasing", &json!({"id": g.0, "ramp": 10})).unwrap(); + assert_eq!(spec(&s, g).1.ease.ramp, 0.1); + // A hidden or locked blend is refused. + for hide in [true, false] { + s.edit("state", |d, _| { + let n = d.node_mut(g).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + Ok(()) + }) + .unwrap(); + let (before, d2) = (spec(&s, g).1, depth(&s)); + let e = s.execute("object.blend.setEasing", &json!({"id": g.0, "colorShift": 5})).unwrap_err().to_string(); + assert!(e.contains("the object is hidden or locked"), "{e}"); + assert_eq!((spec(&s, g).1, depth(&s)), (before, d2)); + } + // A blend made without a spine takes no easing, Align to Path included; its color shift still + // changes. + let (c, d) = (rect(&mut s, 0.0, 300.0, 40.0), rect(&mut s, 200.0, 300.0, 40.0)); + sel(&mut s, &[c, d]); + let n = id_of(&s.execute("object.blend.make", &json!({"createSpine": "never", "orientation": "path"})).unwrap()); + let d3 = depth(&s); + let e = s.execute("object.blend.setEasing", &json!({"id": n.0, "ramp": 30})).unwrap_err().to_string(); + assert!(e.contains("the blend has no spine"), "{e}"); + assert_eq!(depth(&s), d3); + s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap(); + assert_eq!(spec(&s, n).1.color_shift, 10.0); +} + +/// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a +/// drag that ends at its starting value and a drag canceled with Escape record none, and Escape +/// puts the value back. +#[test] +fn dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none() { + let mut s = session(); + let (a, b) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0)); + sel(&mut s, &[a, b]); + let g = id_of(&s.execute("object.blend.make", &json!({"steps": 3, "orientation": "path", "easingType": "easeOut"})).unwrap()); + let v = ViewInfo::default(); + let depth = |s: &Session| s.doc().unwrap().history.undo.len(); + let ptr = |s: &mut Session, kind: PointerKind, x: f64, y: f64| { + s.pointer(&PointerEvent::new(kind, x, y), ViewInfo::default()).unwrap(); + }; + s.select_tool("selection", v).unwrap(); + sel(&mut s, &[g]); + let d0 = depth(&s); + // The Easing widget sits 16 px below the spine's halfway point (120, 20). + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 135.0, 36.0); + ptr(&mut s, PointerKind::Drag, 150.0, 36.0); + ptr(&mut s, PointerKind::Up, 150.0, 36.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, 0.0, d0 + 1)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g], "the blend stays selected"); + // A click, and a drag back to where it started. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 160.0, 36.0); + ptr(&mut s, PointerKind::Drag, 120.0, 36.0); + ptr(&mut s, PointerKind::Up, 120.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // Escape during a drag puts the value back. + ptr(&mut s, PointerKind::Down, 120.0, 36.0); + ptr(&mut s, PointerKind::Drag, 170.0, 36.0); + assert_eq!(spec(&s, g).1.ease.ramp, 0.8, "the drag previews"); + s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap(); + ptr(&mut s, PointerKind::Up, 170.0, 36.0); + assert_eq!((spec(&s, g).1.ease.ramp, depth(&s)), (0.3, d0 + 1)); + // The Blend tool drags the Appearance widget, 16 px above: the color shift alone. + s.select_tool("blend", v).unwrap(); + sel(&mut s, &[g]); + ptr(&mut s, PointerKind::Down, 120.0, 4.0); + ptr(&mut s, PointerKind::Drag, 100.0, 4.0); + ptr(&mut s, PointerKind::Up, 100.0, 4.0); + let sp = spec(&s, g).1; + assert_eq!((sp.ease.kind.id(), sp.ease.ramp, sp.color_shift, depth(&s)), ("easeOut", 0.3, -20.0, d0 + 2)); + assert_eq!(s.doc().unwrap().selection.objects, vec![g]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(spec(&s, g).1.color_shift, 0.0); +} + #[test] fn native_blend_easing_values_clamp_and_non_finite_become_default() { for (bad, ramp, shift) in [ diff --git a/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs new file mode 100644 index 0000000000..634e2639a1 --- /dev/null +++ b/crates/tools/src/blendwidget.rs @@ -0,0 +1,536 @@ +//! Blend widgets: two handles beside the spine of one selected blend, at the point halfway along +//! the spine, one 16 screen pixels above it and one 16 pixels below it. Where other artwork crowds +//! that spot (the boxes of three or more other visible objects hold it), a widget moves further +//! out in 16-pixel steps, up to 144 pixels, to the first spot that is not crowded, and the line +//! the canvas draws from the halfway point to the widget grows with it. The upper one sets the +//! blend's color shift (`colorShift`, -100 to 100) and the lower one its easing strength (`ramp`, +//! 0 to 100). The lower one shows only when the steps follow a spine (a replaced or edited spine, +//! or Align to Path), where easing moves them; a blend made without a spine shows neither. A drag along the spine's direction at the press +//! changes the value by one per screen pixel, starting from the value at the press, and the arrow +//! keys during the drag change it by one (Shift: by ten); Escape or a switch to another tool +//! cancels the drag and puts the value back. Each drag previews +//! `object.blend.setEasing` and is one undo step, or none when the value ends where it started; a +//! click changes nothing. The Selection and Blend tools hit the widgets and the canvas draws them +//! from the same geometry. + +use serde_json::json; +use vectorcraft_doc::live::{Spine, blend_spine}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection}; +use vectorcraft_geom::{Point, Rect, Vec2}; + +use crate::{Action, Overlay, PointerEvent, ToolContext}; + +/// How far (screen px) a widget's center sits above or below the spine's halfway point. +pub const OFFSET_PX: f64 = 16.0; +/// The widget's radius (screen px), as the canvas draws it. +pub const RADIUS_PX: f32 = 4.0; +/// How near (screen px) the pointer must be to hit a widget. +const HIT_PX: f64 = 5.0; +/// Points of value per screen pixel of drag along the spine. +const PER_PX: f64 = 1.0; +/// The undo step a drag records, as the Blend panel's changes do. +const LABEL: &str = "Blend Options"; +/// How many [`OFFSET_PX`] steps further out a widget may move from a crowded spot. +const CLEAR_STEPS: usize = 8; +/// A spot is crowded when the boxes of this many other visible objects hold it. +const CROWDED: usize = 3; +/// The most nodes looked at to find what crowds the widgets, topmost first. +const LOOK: usize = 1024; + +/// What a blend widget sets. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlendControl { + /// The upper widget: the color shift. + Appearance, + /// The lower widget: the easing strength. + Easing, +} + +impl BlendControl { + /// The `object.blend.setEasing` param this widget sets. + fn key(self) -> &'static str { + match self { + Self::Appearance => "colorShift", + Self::Easing => "ramp", + } + } + + /// The values the param takes. + fn range(self) -> (f64, f64) { + match self { + Self::Appearance => (-100.0, 100.0), + Self::Easing => (0.0, 100.0), + } + } +} + +/// One widget of the selected blend. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlendWidget { + pub id: NodeId, + pub control: BlendControl, + /// The spine's halfway point, in the document. + pub on: Point, + /// The widget's center in the document: [`OFFSET_PX`] from `on`, or further out where other + /// artwork crowds that spot. + pub at: Point, + /// The spine's direction at `on` (unit length, in the document). + pub along: Vec2, + /// The value the widget sets, as it is now. + pub value: f64, +} + +impl BlendWidget { + /// The widgets of the selection at `zoom` (screen px per document point), with `up` the + /// document direction of the screen's up: the Appearance widget, then the Easing widget. Empty + /// unless the selection is one whole blend, visible and unlocked, with two or more keys and a + /// spine of some length, and no anchors are selected. Each widget sits at the first spot out + /// from the spine, in [`OFFSET_PX`] steps, that the boxes of fewer than three other visible + /// objects hold, at most nine steps out; where every spot is crowded it stays at the first. + pub fn of(doc: &Document, selection: &Selection, zoom: f64, up: Vec2) -> Vec { + let up_len = up.hypot(); + let usable = |v: f64| v.is_finite() && v > 0.0; + if !selection.anchors.is_empty() || selection.symbol_part.is_some() || !usable(zoom) || !usable(up_len) { + return vec![]; + } + let [id] = selection.objects.as_slice() else { return vec![] }; + let Some(NodeKind::Blend { children, spec }) = doc.node(*id).map(|n| &n.kind) else { return vec![] }; + if children.len() < 2 || !doc.is_editable(*id) { + return vec![]; + } + let Some(spine) = blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)) else { return vec![] }; + if !usable(spine.length()) { + return vec![]; + } + let (on, angle) = spine.at(0.5); + let along = Vec2::new(angle.cos(), angle.sin()); + if !(on.x.is_finite() && on.y.is_finite() && along.x.is_finite() && along.y.is_finite()) { + return vec![]; + } + let lift = up * (OFFSET_PX / zoom / up_len); + let reach = lift.hypot() * (CLEAR_STEPS + 1) as f64 + f64::from(RADIUS_PX) / zoom; + let boxes = boxes_near(doc, *id, Rect::new(on.x - reach, on.y - reach, on.x + reach, on.y + reach)); + let clear = |side: Vec2| { + let crowded = |p: Point| boxes.iter().filter(|b| b.x0 <= p.x && p.x <= b.x1 && b.y0 <= p.y && p.y <= b.y1).count() >= CROWDED; + (1..=CLEAR_STEPS + 1).map(|k| on + side * k as f64).find(|p| !crowded(*p)).unwrap_or(on + side) + }; + let value = |v: f64, control: BlendControl| { + let (lo, hi) = control.range(); + if v.is_finite() { v.clamp(lo, hi) } else { 0.0 } + }; + let shift = value(spec.color_shift, BlendControl::Appearance); + let mut out = vec![Self { id: *id, control: BlendControl::Appearance, on, at: clear(lift), along, value: shift }]; + if spec.eases_along_spine() { + let ramp = value(spec.ease.ramp * 100.0, BlendControl::Easing); + out.push(Self { id: *id, control: BlendControl::Easing, on, at: clear(-lift), along, value: ramp }); + } + out + } + + /// The widgets the tool in `cx` can drag. + pub fn for_tool(cx: &ToolContext) -> Vec { + Self::of(cx.doc, cx.selection, cx.zoom, screen_up(cx)) + } + + /// The widget of `cx` nearest `p` within reach. + fn at_point(cx: &ToolContext, p: Point) -> Option { + let d = |w: &Self| w.at.distance(p); + Self::for_tool(cx).into_iter().filter(|w| d(w) <= cx.tol(HIT_PX)).min_by(|a, b| d(a).total_cmp(&d(b))) + } +} + +/// The document direction of the screen's up (the page's up without a window). +fn screen_up(cx: &ToolContext) -> Vec2 { + cx.screen.map(|s| -s.down).filter(|v| v.x.is_finite() && v.y.is_finite() && v.hypot() > 0.0).unwrap_or(Vec2::new(0.0, -1.0)) +} + +/// The geometric boxes of the visible objects other than blend `skip` that reach into `region`. +/// The walk goes into visible layers and groups without a clipping path, topmost first, and looks +/// at no more than [`LOOK`] nodes; any other object counts by its own box. +fn boxes_near(doc: &Document, skip: NodeId, region: Rect) -> Vec { + let reaches = |b: &Rect| b.x0 <= region.x1 && region.x0 <= b.x1 && b.y0 <= region.y1 && region.y0 <= b.y1; + // Each node is queued at most once, so `room` bounds both the queue and the walk; a container + // with more members than there is room for queues its topmost ones. + fn queue<'a>(nodes: &'a [std::sync::Arc], skip: NodeId, room: &mut usize) -> impl Iterator { + let take = nodes.len().min(*room); + *room -= take; + nodes.iter().skip(nodes.len() - take).filter(move |c| c.visible && c.id != skip).map(|c| &**c) + } + let mut room = LOOK; + let mut stack: Vec<&Node> = queue(&doc.layers, skip, &mut room).collect(); + let mut out = vec![]; + while let Some(n) = stack.pop() { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => { + stack.extend(queue(children, skip, &mut room)); + } + _ => out.extend(n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && reaches(b))), + } + } + out +} + +/// Is `p` over a blend widget? +pub fn over_widget(cx: &ToolContext, p: Point) -> bool { + BlendWidget::at_point(cx, p).is_some() +} + +/// Dragging a blend widget: its value follows the pointer along the spine's direction at the +/// press. +#[derive(Clone, Debug)] +pub struct BlendWidgetDrag { + widget: BlendWidget, + start: Point, + /// The pointer's last position. + at: Point, + /// What the arrow keys added. + keys: f64, + began: bool, + value: f64, +} + +impl BlendWidgetDrag { + /// The drag has left the click: the value is changing. + pub fn adjusting(&self) -> bool { + self.began + } + + /// Start a drag when the press `ev` is on a blend widget of the selection. + pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option { + let widget = BlendWidget::at_point(cx, ev.pos)?; + Some(Self { widget, start: ev.pos, at: ev.pos, keys: 0.0, began: false, value: widget.value }) + } + + /// The value at the press plus the pointer's move along the spine, before the keys' steps + /// and the range. + fn dragged(&self, cx: &ToolContext) -> f64 { + let moved = (self.at - self.start).dot(self.widget.along) * cx.zoom; + self.widget.value + if moved.is_finite() { (moved * PER_PX).round() } else { 0.0 } + } + + /// The value for the pointer at `self.at` and the keys pressed, within the range. + fn value_now(&self, cx: &ToolContext) -> f64 { + let (lo, hi) = self.widget.control.range(); + (self.dragged(cx) + self.keys).clamp(lo, hi) + } + + /// The actions that show the value now: the drag's start when it begins, then a preview. + fn preview(&mut self, cx: &ToolContext) -> Vec { + let mut out = vec![]; + if !self.began { + self.began = true; + out.push(Action::Begin(LABEL.into())); + } + self.value = self.value_now(cx); + let mut params = json!({ "id": self.widget.id.0 }); + params[self.widget.control.key()] = json!(self.value); + out.push(Action::Preview("object.blend.setEasing".into(), params)); + out + } + + pub fn drag(&mut self, cx: &ToolContext, p: Point) -> Vec { + self.at = p; + if !self.began && p.distance(self.start) < cx.tol(3.0) { + return vec![]; + } + self.preview(cx) + } + + /// An arrow key during the drag: one point more (`more`) or less, ten with Shift. + pub fn step(&mut self, cx: &ToolContext, more: bool, big: bool) -> Vec { + let by = if big { 10.0 } else { 1.0 }; + self.keys += if more { by } else { -by }; + // Past either end, the keys stop adding: the way back starts at once. + let (lo, hi) = self.widget.control.range(); + let free = self.dragged(cx); + self.keys = self.keys.clamp(lo - free, hi - free); + self.preview(cx) + } + + pub fn finish(self) -> Vec { + if self.began { vec![Action::Commit] } else { vec![] } + } + + /// The value next to the pointer. + pub fn overlays(&self, cx: &ToolContext) -> Vec { + if !self.began || !cx.measurement_labels { + return vec![]; + } + vec![Overlay::Measure { p: self.at, text: format!("{:.0}%", self.value) }] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::select::SelectionTool; + use crate::testutil::*; + use crate::{Mods, PointerKind, Tool, ToolKey}; + use std::sync::Arc; + use vectorcraft_doc::live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpec}; + use vectorcraft_doc::{Appearance, Node}; + use vectorcraft_geom::{PathData, Rect, SubPath, shapes}; + + const UP: Vec2 = Vec2::new(0.0, -1.0); + + fn square(d: &mut Document, x: f64, y: f64) -> Arc { + let id = d.alloc_id(); + Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), Appearance::default_art())) + } + + /// A blend of squares centered at `centers`, with `spec`, selected. + fn blend_doc(centers: &[(f64, f64)], spec: BlendSpec) -> (Document, NodeId, Selection) { + let mut d = Document::new(800.0, 800.0); + let l = d.layers[0].id; + let children = centers.iter().map(|(x, y)| square(&mut d, x - 10.0, y - 10.0)).collect(); + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(spec) })).unwrap(); + let mut s = Selection::default(); + s.add(id); + (d, id, s) + } + + /// Steps along a spine (Align to Path), eased In Out at 40%, color shift -20. + fn eased() -> BlendSpec { + BlendSpec { + orientation: BlendOrientation::AlignToPath, + ease: BlendEase { kind: BlendEaseType::InOut, ramp: 0.4 }, + color_shift: -20.0, + ..Default::default() + } + } + + fn ev(kind: PointerKind, p: Point) -> PointerEvent { + PointerEvent::new(kind, p.x, p.y) + } + + fn near(a: Point, b: Point) -> bool { + a.distance(b) < 1e-9 + } + + #[test] + fn the_widgets_sit_above_and_below_the_spines_halfway_point() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert_eq!(w.iter().map(|w| w.control).collect::>(), [BlendControl::Appearance, BlendControl::Easing]); + assert!(w.iter().all(|w| w.id == id && near(w.on, Point::new(200.0, 200.0))), "{w:?}"); + assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}"); + assert_eq!((w[0].value, w[1].value), (-20.0, 40.0)); + // 16 screen pixels at any zoom, and above on the screen in a turned view. + assert!(near(BlendWidget::of(&d, &s, 4.0, UP)[0].at, Point::new(200.0, 196.0))); + let turned = BlendWidget::of(&d, &s, 1.0, Vec2::new(1.0, 0.0)); + assert!(near(turned[0].at, Point::new(216.0, 200.0)) && near(turned[1].at, Point::new(184.0, 200.0)), "{turned:?}"); + // Halfway along by length: three keys spaced unevenly. + let (d, _, s) = blend_doc(&[(0.0, 0.0), (100.0, 0.0), (100.0, 300.0)], eased()); + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].on, Point::new(100.0, 100.0))); + } + + /// Adds a filled rectangle from `(x0, y0)` to `(x1, y1)` on top of the first layer. + fn cover(d: &mut Document, x0: f64, y0: f64, x1: f64, y1: f64) -> NodeId { + let l = d.layers[0].id; + let id = d.alloc_id(); + let at = d.layers[0].children().map_or(0, |c| c.len()); + d.insert(Some(l), at, Node::path(id, shapes::rectangle(Rect::new(x0, y0, x1, y1)), Appearance::default_art())).unwrap(); + id + } + + #[test] + fn a_widget_moves_out_from_crowded_artwork_and_its_line_follows() { + let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + // Two objects under the Appearance widget's spot do not crowd it. + for _ in 0..2 { + cover(&mut d, 190.0, 175.0, 210.0, 195.0); + } + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + // A third does: the widget moves 16 px further out, past them, and the Easing widget below + // stays where it is. The halfway point it joins stays the same. + let third = cover(&mut d, 190.0, 175.0, 210.0, 195.0); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(near(w[0].at, Point::new(200.0, 168.0)) && near(w[0].on, Point::new(200.0, 200.0)), "{w:?}"); + assert!(near(w[1].at, Point::new(200.0, 216.0)), "{w:?}"); + // A hidden object does not count. + d.node_mut(third).unwrap().visible = false; + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + // The walk looks at the topmost 1024 nodes only: with more objects above them, the covers + // below are not looked at. + d.node_mut(third).unwrap().visible = true; + let mut many = d.clone(); + for i in 0..LOOK { + let x = (i % 30) as f64 * 25.0; + cover(&mut many, x, 500.0, x + 10.0, 510.0); + } + assert!(near(BlendWidget::of(&many, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 168.0))); + // Crowded all the way out (past 144 px): the widget stays at the first spot. + let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + for _ in 0..3 { + cover(&mut d, 150.0, 0.0, 250.0, 199.0); + } + assert!(near(BlendWidget::of(&d, &s, 1.0, UP)[0].at, Point::new(200.0, 184.0))); + // The blend's own keys never crowd it: three 100 pt keys that all hold both spots. + let mut d = Document::new(800.0, 800.0); + let l = d.layers[0].id; + let big = |d: &mut Document, x: f64| { + let id = d.alloc_id(); + Arc::new(Node::path(id, shapes::rectangle(Rect::new(x - 50.0, 150.0, x + 50.0, 250.0)), Appearance::default_art())) + }; + let children = vec![big(&mut d, 150.0), big(&mut d, 200.0), big(&mut d, 250.0)]; + let id = d.alloc_id(); + d.insert(Some(l), 0, Node::new(id, NodeKind::Blend { children, spec: Box::new(eased()) })).unwrap(); + let mut s = Selection::default(); + s.add(id); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert!(near(w[0].at, Point::new(200.0, 184.0)) && near(w[1].at, Point::new(200.0, 216.0)), "{w:?}"); + } + + #[test] + fn the_easing_widget_needs_steps_that_follow_a_spine() { + // The straight lines between the keys with Align to Page: the steps do not follow a spine. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec::default()); + assert_eq!(BlendWidget::of(&d, &s, 1.0, UP).iter().map(|w| w.control).collect::>(), [BlendControl::Appearance]); + // A replaced spine, closed, with the keys spread along it. + let spine = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(200.0, 0.0), Point::new(200.0, 200.0)], true)); + let (d, _, s) = blend_doc(&[(0.0, 0.0), (200.0, 0.0)], BlendSpec { spine: Some(spine), ..Default::default() }); + let w = BlendWidget::of(&d, &s, 1.0, UP); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w[0].on.x.is_finite() && w[0].on.y.is_finite()); + } + + #[test] + fn no_widgets_for_a_selection_they_cannot_edit() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + assert!(BlendWidget::of(&d, &Selection::default(), 1.0, UP).is_empty(), "nothing selected"); + assert!(BlendWidget::of(&d, &s, 0.0, UP).is_empty() && BlendWidget::of(&d, &s, f64::NAN, UP).is_empty(), "no zoom"); + assert!(BlendWidget::of(&d, &s, 1.0, Vec2::ZERO).is_empty(), "no up"); + // Two blends, or a blend and another object. + let (mut two, a, _) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let l = two.layers[0].id; + let keys = vec![square(&mut two, 0.0, 400.0), square(&mut two, 200.0, 400.0)]; + let b = two.alloc_id(); + two.insert(Some(l), 0, Node::new(b, NodeKind::Blend { children: keys, spec: Box::new(eased()) })).unwrap(); + let mut both = Selection::default(); + both.add(a); + both.add(b); + assert!(BlendWidget::of(&two, &both, 1.0, UP).is_empty(), "two blends"); + // Locked or hidden. + for hide in [false, true] { + let mut d = d.clone(); + let n = d.node_mut(id).unwrap(); + (n.visible, n.locked) = (!hide, !hide); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "hidden: {hide}"); + } + // A key object selected on its own is not the whole blend. + let NodeKind::Blend { children, .. } = &d.node(id).unwrap().kind else { panic!("not a blend") }; + let mut key = Selection::default(); + key.add(children[0].id); + assert!(BlendWidget::of(&d, &key, 1.0, UP).is_empty(), "a key"); + // Keys on one spot: the spine has no length. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (100.0, 200.0)], eased()); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no length"); + // One key. + let (d, _, s) = blend_doc(&[(100.0, 200.0)], eased()); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "one key"); + // A blend made without a spine (Create Spine › Never), Align to Path included. + let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], BlendSpec { no_spine: true, ..eased() }); + assert!(BlendWidget::of(&d, &s, 1.0, UP).is_empty(), "no spine"); + } + + /// A drag of the Easing widget along the spine changes the ramp by one per pixel from its + /// value at the press, in one undo step, without changing the type or the color shift. + #[test] + fn dragging_the_easing_widget_sets_the_ramp_from_its_value() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let at = Point::new(200.0, 216.0); + assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty()); + // A small move first: no jump, the value stays at the press's 40. + assert!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(1.0, 1.0))).is_empty()); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(3.0, 30.0))); + assert_eq!(a[0], Action::Begin(LABEL.into())); + assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 43.0})), "across the spine counts for nothing"); + assert!(t.transforming()); + assert!(t.overlays(&c).iter().any(|o| matches!(o, Overlay::Measure { text, .. } if text == "43%"))); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-70.0, 0.0))); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 0.0}))], "clamped at 0"); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(500.0, 0.0))); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 100.0}))], "clamped at 100"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at)), vec![Action::Commit]); + // A click that doesn't move sends nothing. + assert!(t.pointer(&c, &ev(PointerKind::Down, at)).is_empty()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty()); + } + + /// The Appearance widget sets the color shift alone, and at 4× zoom a pixel is a quarter point. + #[test] + fn dragging_the_appearance_widget_sets_the_color_shift() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = ToolContext { zoom: 4.0, ..cx(&d, &s, &p) }; + let mut t = SelectionTool::default(); + let at = Point::new(200.0, 196.0); + t.pointer(&c, &ev(PointerKind::Down, at)); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-5.0, 0.0))); + assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -40.0}))); + let a = t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(-50.0, 0.0))); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "colorShift": -100.0}))]); + } + + /// The drag follows the spine's direction at the press: along a vertical spine, down adds. + #[test] + fn the_drag_follows_the_spines_direction() { + let (d, id, s) = blend_doc(&[(200.0, 100.0), (200.0, 300.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let w = BlendWidget::of(&d, &s, 1.0, UP); + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, w[1].at)); + let a = t.pointer(&c, &ev(PointerKind::Drag, w[1].at + Vec2::new(40.0, 10.0))); + assert_eq!(a[1], Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 50.0}))); + } + + /// Switching away from the Selection or Blend tool during a drag cancels it, and after a press + /// that has not moved there is nothing to put back. + #[test] + fn a_tool_switch_cancels_the_drag() { + let (d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 216.0); + let tools: [Box; 2] = [Box::new(SelectionTool::default()), Box::new(crate::meshblend::BlendTool::default())]; + for mut t in tools { + t.pointer(&c, &ev(PointerKind::Down, at)); + assert_eq!(t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(20.0, 0.0))).len(), 2); + assert_eq!(t.deactivate(&c), vec![Action::Cancel]); + assert!(!t.busy() && !t.transforming()); + t.pointer(&c, &ev(PointerKind::Down, at)); + assert!(t.busy()); + assert!(t.deactivate(&c).is_empty()); + assert!(!t.busy()); + } + } + + /// The arrow keys during a drag change the value by one, ten with Shift, and Escape cancels. + #[test] + fn arrow_keys_step_and_escape_cancels() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let at = Point::new(200.0, 216.0); + t.pointer(&c, &ev(PointerKind::Down, at)); + let a = t.key(&c, ToolKey::Up, Mods::default()); + assert_eq!(a, vec![Action::Begin(LABEL.into()), Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 41.0}))]); + let a = t.key(&c, ToolKey::Left, Mods { shift: true, ..Default::default() }); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 31.0}))]); + for _ in 0..10 { + t.key(&c, ToolKey::Down, Mods { shift: true, ..Default::default() }); + } + let a = t.key(&c, ToolKey::Right, Mods::default()); + assert_eq!(a, vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": 1.0}))], "steps past 0 are not kept"); + assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::Cancel]); + assert!(!t.busy() && !t.transforming()); + assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty(), "the release commits nothing"); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c397231495..b8069e9271 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -9,6 +9,7 @@ pub mod addons; pub mod bbox; +pub mod blendwidget; pub mod builder; pub mod catalog; pub mod corners; diff --git a/crates/tools/src/meshblend.rs b/crates/tools/src/meshblend.rs index d64b4b162b..d61eb95f5f 100644 --- a/crates/tools/src/meshblend.rs +++ b/crates/tools/src/meshblend.rs @@ -4,6 +4,7 @@ //! point of a path blends from that point (`starts`). Each further click adds the object as one //! more key of the new blend (as does clicking a blend first). Double-click or Alt-click opens //! Blend Options (dialog `blendOptions`). Clicking empty canvas or pressing Escape starts over. +//! A press on a widget of the selected blend drags it ([`crate::blendwidget`]). //! - Mesh: click inside a filled path → `object.mesh.create {ids, at}` (1×1 mesh plus lines through //! the click); click inside a mesh or a mesh envelope → `object.mesh.addLine` (a gradient mesh's //! new point takes the current fill color in its own model, unless Shift); drag a mesh point, @@ -19,6 +20,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{NodeId, NodeKind}; use vectorcraft_geom::Point; +use crate::blendwidget::{self, BlendWidgetDrag}; use crate::meshedit::{MeshEdit, grid_of}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; @@ -51,6 +53,8 @@ pub struct BlendTool { /// `first` and `chained` when the button went down: a dropped press puts them back, unless /// the press picked an object or made a blend. before_press: Option<(Option, bool)>, + /// A press on a widget of the selected blend, and its drag. + widget: Option, } /// The anchor of the first subpath of path or compound path `id` (its first member's) nearest @@ -98,6 +102,19 @@ impl Tool for BlendTool { if self.first.is_some_and(|(a, _)| cx.doc.node(a).is_none()) { (self.first, self.chained) = (None, false); } + // The selected blend's widgets take a press before the picks, and its drag. + match (ev.kind, &mut self.widget) { + (PointerKind::Drag, Some(d)) => return d.drag(cx, ev.pos), + (PointerKind::Up, Some(_)) => return self.widget.take().map(BlendWidgetDrag::finish).unwrap_or_default(), + (PointerKind::DoubleClick, _) if blendwidget::over_widget(cx, ev.pos) => return vec![], + (PointerKind::Down, _) if !ev.mods.alt => { + if let Some(d) = BlendWidgetDrag::hit(cx, ev) { + self.widget = Some(d); + return vec![]; + } + } + _ => {} + } match ev.kind { PointerKind::DoubleClick => options(self), PointerKind::Down if ev.mods.alt => options(self), @@ -131,6 +148,9 @@ impl Tool for BlendTool { } } fn overlays(&self, cx: &ToolContext) -> Vec { + if let Some(d) = &self.widget { + return d.overlays(cx); + } let Some((id, start)) = self.first(cx) else { return vec![] }; let Some(n) = cx.doc.node(id) else { return vec![] }; let at = match (start, &n.kind) { @@ -144,6 +164,9 @@ impl Tool for BlendTool { vec![Overlay::Anchor { p, color: FEEDBACK, filled: true, size: 6.0 }] } fn cursor(&self, cx: &ToolContext, p: Point, _mods: Mods) -> Cursor { + if self.widget.is_some() || blendwidget::over_widget(cx, p) { + return Cursor::ArrowHollow; + } match pick(cx, p) { Some((_, Some(_))) => Cursor::BlendAnchor, Some(_) => Cursor::BlendObject, @@ -151,15 +174,34 @@ impl Tool for BlendTool { } } fn busy(&self) -> bool { - self.first.is_some() + self.first.is_some() || self.widget.is_some() + } + fn transforming(&self) -> bool { + self.widget.as_ref().is_some_and(BlendWidgetDrag::adjusting) + } + /// The arrows while a blend widget is pressed: its value. + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { + self.widget.is_some() && matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right) } /// A first pick, or adding to the blend just made: Escape ends either. fn ends_on_escape(&self, cx: &ToolContext) -> bool { self.first(cx).is_some() } /// Escape drops the object picked first and stops adding to the blend just made, so the next - /// click picks a new first object. The document and the selection stay as they are. - fn key(&mut self, _cx: &ToolContext, key: crate::ToolKey, _mods: Mods) -> Vec { + /// click picks a new first object. The document and the selection stay as they are. While a + /// blend widget is pressed, the arrows change its value and Escape cancels its drag instead. + fn key(&mut self, cx: &ToolContext, key: crate::ToolKey, mods: Mods) -> Vec { + if let Some(d) = &mut self.widget { + return match key { + ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right => d.step(cx, matches!(key, ToolKey::Up | ToolKey::Right), mods.shift), + ToolKey::Escape => { + let began = d.adjusting(); + self.widget = None; + if began { vec![Action::Cancel] } else { vec![] } + } + _ => vec![], + }; + } if key == crate::ToolKey::Escape { (self.first, self.chained, self.before_press) = (None, false, None); } @@ -172,11 +214,18 @@ impl Tool for BlendTool { if let Some((first, chained)) = self.before_press.take() { (self.first, self.chained) = (first, chained); } - vec![] + // A dropped widget drag puts its value back. + match self.widget.take() { + Some(d) if d.adjusting() => vec![Action::Cancel], + _ => vec![], + } } fn deactivate(&mut self, _cx: &ToolContext) -> Vec { (self.first, self.chained, self.before_press) = (None, false, None); - vec![] + match self.widget.take() { + Some(d) if d.adjusting() => vec![Action::Cancel], + _ => vec![], + } } /// Choosing the Blend tool again finishes the blend: it drops the object picked first and /// stops adding to the blend just made, so the next click picks a new first object. The diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 1658346127..84a10d350f 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -45,6 +45,7 @@ use vectorcraft_doc::{NodeId, OrientedBox}; use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag}; +use crate::blendwidget::{self, BlendWidgetDrag}; use crate::corners::{self, CornerDrag, over_widget}; use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path}; use crate::objects_on_path::{MoveAllDrag, RotateAllDrag, SpaceDrag, over_move_all, over_rotate_all, over_space}; @@ -129,6 +130,8 @@ enum State { Corner(CornerDrag), /// Dragging a live ellipse's pie widget. Pie(PieDrag), + /// Dragging a selected blend's Appearance or Easing widget. + BlendWidget(BlendWidgetDrag), /// Dragging an Objects on Path group's Rotate All widget. RotateAll(RotateAllDrag), /// Dragging an Objects on Path group's Space widget. @@ -753,15 +756,19 @@ impl Tool for SelectionTool { /// E during a marquee drag switches the marquee between the objects it touches and only the /// objects inside it. Escape during a drag (a move or an Alt-drag copy, a bounding-box scale or - /// rotation, a marquee, a corner widget, a pie widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror + /// rotation, a marquee, a corner widget, a pie widget, a blend widget, the Rotate All, Space or Move All widget, the brackets of type on a path, a mirror /// axis, a grid repeat's handles, a ruler guide) cancels it: what it changed goes back, no copy /// is left, and the tool waits for the next press. With the pointer loaded with text, Escape /// drops the text. - fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { // Up and Down while a corner widget is pressed: the corners' kind (#2898). if let (ToolKey::Up | ToolKey::Down, State::Corner(c)) = (key, &mut self.state) { return c.step_kind(key == ToolKey::Up); } + // The arrows while a blend widget is pressed: its value, by one (Shift: ten). + if let (ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right, State::BlendWidget(d)) = (key, &mut self.state) { + return d.step(cx, matches!(key, ToolKey::Up | ToolKey::Right), mods.shift); + } if key == ToolKey::Letter('e') && let State::Marquee { enclosed, .. } = &mut self.state { @@ -812,10 +819,23 @@ impl Tool for SelectionTool { out } + /// Switching tools during a blend widget's drag cancels it, as Escape does: its value goes back. + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + match std::mem::take(&mut self.state) { + State::BlendWidget(d) if d.adjusting() => vec![Action::Cancel], + State::BlendWidget(_) => vec![], + other => { + self.state = other; + vec![] + } + } + } + fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool { match self.state { State::Marquee { .. } => key == ToolKey::Letter('e'), State::Corner(_) => matches!(key, ToolKey::Up | ToolKey::Down), + State::BlendWidget(_) => matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right), State::Loaded { .. } | State::Mirror(_) | State::GridRepeat(_) | State::NineSlice(_) => key == ToolKey::Escape, _ => false, } @@ -842,6 +862,7 @@ impl Tool for SelectionTool { State::Moving { began: true, .. } | State::Scaling { .. } | State::Rotating { .. } => true, State::Corner(c) => c.adjusting(), State::Pie(d) => d.adjusting(), + State::BlendWidget(d) => d.adjusting(), State::RotateAll(d) => d.adjusting(), State::Space(d) => d.adjusting(), State::MoveAll(d) => d.adjusting(), @@ -1015,6 +1036,15 @@ impl Tool for SelectionTool { self.state = State::Idle; d.finish() } + (PointerKind::Drag, State::BlendWidget(mut d)) => { + let out = d.drag(cx, p); + self.state = State::BlendWidget(d); + out + } + (PointerKind::Up, State::BlendWidget(d)) => { + self.state = State::Idle; + d.finish() + } (PointerKind::Drag, State::RotateAll(mut d)) => { let out = d.drag(cx, p, m.shift); self.state = State::RotateAll(d); @@ -1104,6 +1134,7 @@ impl Tool for SelectionTool { }), State::Corner(c) => o.extend(c.overlays(cx)), State::Pie(d) => o.extend(d.overlays(cx)), + State::BlendWidget(d) => o.extend(d.overlays(cx)), State::RotateAll(d) => o.extend(d.overlays(cx)), State::Space(d) => o.extend(d.overlays(cx)), State::MoveAll(d) => o.extend(d.overlays(cx)), @@ -1155,7 +1186,7 @@ impl Tool for SelectionTool { State::Moving { began: true, .. } if m.alt => return Cursor::SelectionCopy, State::Moving { began: true, .. } => return if self.snapped { Cursor::ArrowHollow } else { Cursor::Arrow }, State::Corner(_) => return Cursor::CornerRadius, - State::Pie(_) => return Cursor::ArrowHollow, + State::Pie(_) | State::BlendWidget(_) => return Cursor::ArrowHollow, State::RotateAll(_) => return Cursor::Rotate, State::Space(_) => return Cursor::ArrowHollow, State::MoveAll(_) => return Cursor::Move, @@ -1178,6 +1209,9 @@ impl Tool for SelectionTool { if near_selected && pie::over_widget(cx, p) { return Cursor::ArrowHollow; } + if blendwidget::over_widget(cx, p) { + return Cursor::ArrowHollow; + } if over_rotate_all(cx, p) { return Cursor::Rotate; } @@ -1536,7 +1570,7 @@ impl SelectionTool { self.state = State::Loaded { from, before: port == crate::text::Port::In, press: None }; return Some(vec![]); } - // 1. Live Corners widgets, pie widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, blend widgets, the Rotate All, Space and Move All widgets and type on a path's brackets, then the // bounding-box handles of the current selection. if let Some(c) = CornerDrag::hit(cx, ev, false) { self.state = State::Corner(c); @@ -1546,6 +1580,10 @@ impl SelectionTool { self.state = State::Pie(d); return Some(vec![]); } + if let Some(d) = BlendWidgetDrag::hit(cx, ev) { + self.state = State::BlendWidget(d); + return Some(vec![]); + } if let Some(d) = RotateAllDrag::hit(cx, ev) { self.state = State::RotateAll(d); return Some(vec![]); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 50136f1a9c..f3acf5ddb5 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -61,7 +61,7 @@ use outline::KEY_OUTLINE; use outline::{OVERLAY_MAX_ANCHORS, c32, node_outline, walk_drawn}; #[cfg(test)] use overlays::selected_anchor; -use overlays::{HandleLook, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; +use overlays::{HandleLook, blend_widgets_overlay, draw_inside_overlay, draw_overlays, hover_highlight, ime_geometry, selection_overlay}; pub use panes::Panes; pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; @@ -1196,6 +1196,9 @@ fn selection_visuals( hidden_chars_overlay(app, painter, xf); } } + if !revolve_edit { + blend_widgets_overlay(app, painter, xf); + } // Edit › Spelling › Auto Spell Check: shown with the edges hidden too, as it marks the type // rather than the selection. if app.ui.view.auto_spell_check && !revolve_edit { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 8ff6faaf79..b00db601a0 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -238,6 +238,26 @@ pub(super) fn selection_overlay(app: &mut VectorcraftApp, p: &egui::Painter, xf: rotate_all_widget(p, xf, tool, &st.doc, &st.selection); } +/// The Appearance and Easing widgets of one selected blend (Selection and Blend tools), each joined +/// by a line to the spine's halfway point: the Appearance widget hollow above it, the Easing widget +/// filled below it. Drawn with the edges hidden too, as they are controls. +pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf: &Xf) { + use vectorcraft_tools::blendwidget::{BlendControl, BlendWidget, RADIUS_PX}; + if !matches!(app.session.tool_id(), "selection" | "blend") { + return; + } + let Some(st) = app.session.active() else { return }; + // The screen's up in the document, with the view turned by `xf.rot`. + let up = vectorcraft_geom::Vec2::new(-xf.rot.sin(), -xf.rot.cos()); + for w in BlendWidget::of(&st.doc, &st.selection, xf.zoom, up) { + let (on, c, color) = (xf.to_screen(w.on), xf.to_screen(w.at), c32(st.doc.layer_color(w.id))); + p.line_segment([on, c], Stroke::new(1.0, color)); + let fill = if w.control == BlendControl::Easing { color } else { Color32::WHITE }; + p.circle_filled(c, RADIUS_PX, fill); + p.circle_stroke(c, RADIUS_PX, Stroke::new(1.0, color)); + } +} + /// The Rotate All, Space and Move All widgets (Selection and Objects on Path tools, one Objects on Path /// group selected). The Rotate All widget is joined by a line to the first object's attachment /// point. It stays while dragged. diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 57c345505a..1097520565 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1981,6 +1981,7 @@ font-technology Variable Променливи Move Slices Преместване на резени Move Spine Point Преместване на точка от оста Reshape Spine Segment Промяна на формата на сегмент от оста + Blend Easing Плавност на преливането Move Brush Преместване на четка Move Swatch Преместване на образец Move Up Преместване нагоре diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index e28312f750..40ffada862 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3526,6 +3526,7 @@ theme Appearance Izgled Move Mesh Points Pomjeri tačke mreže Move Spine Point Pomjeri tačku kičme Reshape Spine Segment Preoblikuj segment kičme + Blend Easing Ublažavanje stapanja Reset with Mesh Vrati mrežom Reset with Warp Vrati iskrivljenjem Set Mesh Point Color Postavi boju tačke mreže diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 21ac28a103..ba61f3b658 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3563,6 +3563,7 @@ digits Malayalam Malaiàlam Reshape Segment Remodela el segment Reshape Spine Remodela l’espina Reshape Spine Segment Remodela el segment de l’espina + Blend Easing Suavitzat de la fusió Reshape Tool Eina de remodelació Reshape Type Area Remodela l’àrea de text Resize Redimensiona diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 536e373533..0d9d0fc7a1 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3576,6 +3576,7 @@ theme Appearance Motiv Move Mesh Points Přesunout body sítě Move Spine Point Přesunout bod páteře Reshape Spine Segment Přetvarovat segment páteře + Blend Easing Náběh prolnutí Reset with Mesh Obnovit sítí Reset with Warp Obnovit pokřivením Set Mesh Point Color Nastavit barvu bodu sítě diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 64c8e60dad..1427d16f70 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3313,6 +3313,7 @@ theme Appearance Udseende Move Mesh Points Flyt meshpunkter Move Spine Point Flyt rygradpunkt Reshape Spine Segment Omform rygradsegment + Blend Easing Blandingens acceleration Reset with Mesh Nulstil med mesh Reset with Warp Nulstil med forvrængning Set Mesh Point Color Angiv farve for meshpunkt diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index c5604d4a6d..47ef33118e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2057,6 +2057,7 @@ file-filter Templates Vorlagen Move Slices Slices verschieben Move Spine Point Achsenpunkt verschieben Reshape Spine Segment Achsensegment umformen + Blend Easing Beschleunigung der Angleichung Move Brush Pinsel verschieben Move Swatch Farbfeld verschieben Move Up Nach oben verschieben diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 7663ef5045..f761717b3c 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2887,6 +2887,7 @@ theme Appearance Εμφάνιση Move Slices Μετακίνηση τομών Move Spine Point Μετακίνηση σημείου κεντρικής καμπύλης Reshape Spine Segment Αναδιαμόρφωση τμήματος κεντρικής καμπύλης + Blend Easing Επιτάχυνση ανάμειξης Move Brush Μετακίνηση πινέλου Move Swatch Μετακίνηση δείγματος χρώματος Move Up Μετακίνηση επάνω diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2e7a2baed1..a5852cd370 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2051,6 +2051,7 @@ font-technology Variable Variables Move Slices Mover sectores Move Spine Point Mover punto del eje Reshape Spine Segment Remodelar segmento del eje + Blend Easing Suavizado de fusión Move Brush Mover pincel Move Swatch Mover muestra Move Up Subir diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 2967ba4320..6d9750ac23 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3514,6 +3514,7 @@ theme Appearance Ulkoasu Move Mesh Points Siirrä verkkopisteitä Move Spine Point Siirrä selkärangan pistettä Reshape Spine Segment Muotoile selkärangan segmentti uudelleen + Blend Easing Sulautuksen pehmennys Reset with Mesh Palauta verkolla Reset with Warp Palauta vääristyksellä Set Mesh Point Color Aseta verkkopisteen väri diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 187b0a89a1..ef1cc3a6a4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2045,6 +2045,7 @@ font-technology Variable Variables Move Slices Déplacer les tranches Move Spine Point Déplacer le point de la dorsale Reshape Spine Segment Modeler le segment de la dorsale + Blend Easing Lissage du dégradé de formes Move Brush Déplacer la forme Move Swatch Déplacer la nuance Move Up Monter diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 4eb6f4f69e..c1fe72884f 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3884,6 +3884,7 @@ antiAlias Sharp शार्प Reshape Segment सेगमेंट को आकार देना Reshape Spine स्पाइन का आकार बदलें Reshape Spine Segment स्पिन सेगमेंट को फिर से आकार दें + Blend Easing ब्लेंड ईज़िंग Reshape Tool रीशेप टूल Reshape Type Area रीशैप टाइप एरिया Reshape Type Path टाइप पाथ रीशेप करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c60668d353..8c9a9a7c8f 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3430,6 +3430,7 @@ mark-style Japanese Japán Reshape Átformálás Reshape Segment Szakasz átformálása Reshape Spine Segment Gerincszakasz átformálása + Blend Easing Összemosás gyorsítása Reshape Tool Átformálás Reshape Type Area Szövegterület átformálása Resize Átméretezés diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 2be4052fc1..d30c9a39b0 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3313,6 +3313,7 @@ theme Appearance Tampilan Move Mesh Points Pindahkan titik-titik mesh Move Spine Point Pindahkan titik spine Reshape Spine Segment Bentuk ulang segmen spine + Blend Easing Easing blend Reset with Mesh Atur ulang dengan mesh Reset with Warp Atur ulang dengan warp Set Mesh Point Color Tetapkan warna titik mesh diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 9711fbd158..997cbec145 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2089,6 +2089,7 @@ file-filter Templates Modelli Move Slices Sposta sezioni Move Spine Point Sposta punto dorsale Reshape Spine Segment Rimodella segmento della dorsale + Blend Easing Andamento fusione Move Brush Sposta pennello Move Swatch Sposta campione Move Up Sposta su diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 571df35191..b89f0b6046 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2298,6 +2298,7 @@ antiAlias Sharp シャープ Move Slices スライスを移動 Move Spine Point スパインポイントを移動 Reshape Spine Segment スパインセグメントをシェイプ変形 + Blend Easing ブレンドのイージング Move Brush ブラシを移動 Move Swatch スウォッチを移動 Move Up 上へ移動 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 98281d92d9..cd1de08b8f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3156,6 +3156,7 @@ theme Appearance იერსახე Move Mesh Points ბადის წერტილების გადაადგილება Move Spine Point ხერხემლის წერტილის გადაადგილება Reshape Spine Segment ხერხემლის სეგმენტის ფორმის შეცვლა + Blend Easing გადასვლის შერბილება Reset with Mesh ბადით ჩამოყრა Reset with Warp გაღუნვით ხელახლა დაყენება Set Mesh Point Color ბადის წერტილის ფერის დაყენება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index cad90f44ed..d37c9ce6ac 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2288,6 +2288,7 @@ antiAlias Sharp 날카롭게 Move Slices 슬라이스 이동 Move Spine Point 스파인 포인트 이동 Reshape Spine Segment 스파인 세그먼트 모양 변경 + Blend Easing 블렌드 이징 Move Brush 브러시 이동 Move Swatch 견본 이동 Move Up 위로 이동 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 9d71bc2932..76fd327220 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3327,6 +3327,7 @@ theme Appearance Vormgeving Move Mesh Points Maaspunten verplaatsen Move Spine Point Aspunt verplaatsen Reshape Spine Segment Segment van de as opnieuw vormgeven + Blend Easing Blend-easing Reset with Mesh Opnieuw instellen met maas Reset with Warp Opnieuw instellen met vervorming Set Mesh Point Color Kleur van maaspunt instellen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1e4ff15acb..a4057609a3 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3455,6 +3455,7 @@ theme Appearance Wygląd Move Mesh Points Przesuń punkty siatki Move Spine Point Przesuń punkt grzbietu Reshape Spine Segment Zmień kształt segmentu grzbietu + Blend Easing Wygładzanie przejścia Reset with Mesh Resetuj z siatką Reset with Warp Resetuj z wypaczeniem Set Mesh Point Color Ustaw kolor punktu siatki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 028582361f..1ecb16082b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1735,6 +1735,7 @@ antiAlias Sharp Nítido Move Slices Mover Fatias Move Spine Point Mover Ponto de Coluna Reshape Spine Segment Remodelar Segmento da Coluna + Blend Easing Suavização da Mesclagem Move Brush Mover Pincel Move Swatch Mover Amostra Move Up Mover para Cima diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 342d9884e7..03a52086ec 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2078,6 +2078,7 @@ file-filter Templates Шаблоны Move Slices Переместить фрагменты Move Spine Point Переместить точку остова Reshape Spine Segment Изменить форму сегмента остова + Blend Easing Сглаживание перехода Move Brush Переместить кисть Move Swatch Переместить образец Move Up Переместить вверх diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cca9d052cf..a274507c98 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1992,6 +1992,7 @@ font-technology Variable Variabla Move Slices Flytta skivor Move Spine Point Flytta ryggradspunkt Reshape Spine Segment Omforma ryggradssegment + Blend Easing Blandningens acceleration Move Brush Flytta pensel Move Swatch Flytta färgruta Move Up Flytta upp diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 3a2177b404..f0321ad326 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1881,6 +1881,7 @@ font-technology Variable Değişken Move Slices Dilimleri Taşı Move Spine Point Omurga Noktasını Taşı Reshape Spine Segment Omurga Segmentini Yeniden Şekillendirin + Blend Easing Karışım Yumuşatma Move Swatch Renk Örneğini Taşı Move Up Yukarı Taşı Move… Taşı… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 67df483102..471a3cf7a1 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2083,6 +2083,7 @@ file-filter Templates Шаблони Move Slices Перемістити фрагменти Move Spine Point Перемістити точку осі Reshape Spine Segment Змінити форму сегмента осі + Blend Easing Згладжування переходу Move Brush Перемістити пензель Move Swatch Перемістити зразок Move Up Перемістити вгору diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 174b7c7dce..274dfd3b1d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3199,6 +3199,7 @@ mark-style Japanese Kiểu Nhật Reshape Định hình lại Reshape Segment Định hình lại đoạn Reshape Spine Segment Định hình lại đoạn trục + Blend Easing Gia tốc hòa trộn Reshape Tool Công cụ Định hình lại Reshape Type Area Định hình lại vùng chữ Resize Thay đổi kích thước diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 515600d033..0110916f03 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1945,6 +1945,7 @@ file-filter Templates 模板 Move Slices 移动切片 Move Spine Point 移动脊线点 Reshape Spine Segment 整形脊线线段 + Blend Easing 渐变缓动 @msg select a blend or give `id` 请选择一个混合对象,或给出 `id` @msg not a blend 不是混合对象 @msg the blend has no spine 混合对象没有脊线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 31472f0d4b..e020bc6ddd 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3032,6 +3032,7 @@ file-filter Templates 範本 Move Slices 移動切片 Move Spine Point 移動脊線點 Reshape Spine Segment 改變脊線線段外框 + Blend Easing 漸變緩動 @msg select a blend or give `id` 請選取漸變物件,或提供 `id` @msg not a blend 不是漸變物件 @msg the blend has no spine 漸變物件沒有脊線 diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index df87187a65..6e67b1493b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -723,8 +723,20 @@ fn busy_tool_keys(app: &mut VectorcraftApp, ctx: &egui::Context, view: ViewInfo) // The arrows while dragging: a polygon's sides, a grid's rows (↑/↓) and columns (←/→)… let arrows = [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]; for (k, tk) in arrows { + // The press's Shift and Alt (the consume below takes those presses too) go to a tool that + // claims the arrow (Shift steps a blend widget by ten); other tools get the plain arrow. + let pressed = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, modifiers, .. } if *key == k && !(modifiers.command || modifiers.ctrl) => Some(*modifiers), + _ => None, + }) + }); if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { - let _ = app.session.tool_key(tk, Mods::default(), view); + let mods = match pressed { + Some(m) if app.session.tool_claims_key(tk, view) => crate::canvas::mods(m, false), + _ => Mods::default(), + }; + let _ = app.session.tool_key(tk, mods, view); } } // Digits (5 while dragging with the Perspective Selection tool), once per press. diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 44d438b73b..fd4603162f 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -2728,6 +2728,38 @@ fn a_plain_up_nudges_unless_a_corner_widget_is_pressed() { assert_eq!(kind2, CornerKind::InvertedRound, "Up stepped the kind"); } +/// While a blend widget is pressed, an arrow steps its value by one and Shift with an arrow by ten. +#[test] +fn shift_arrows_step_a_pressed_blend_widget_by_ten() { + use vectorcraft_doc::{NodeId, NodeKind}; + use vectorcraft_tools::blendwidget::BlendWidget; + use vectorcraft_tools::{PointerEvent, PointerKind}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let rect = |app: &mut VectorcraftApp, x: f64| { + app.session.execute("shape.rectangle", &json!({"x": x, "y": 190, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap() + }; + let (a, b) = (rect(&mut app, 90.0), rect(&mut app, 290.0)); + app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let id = NodeId(app.session.execute("object.blend.make", &json!({})).unwrap()["id"].as_u64().unwrap()); + app.select_tool("selection"); + app.session.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + let shift = |app: &VectorcraftApp| { + let NodeKind::Blend { spec, .. } = &app.session.active().unwrap().doc.node(id).unwrap().kind else { panic!("not a blend") }; + spec.color_shift + }; + let view = app.view_info(); + let st = app.session.active().unwrap(); + let at = BlendWidget::of(&st.doc, &st.selection, view.zoom, vectorcraft_geom::Vec2::new(0.0, -1.0))[0].at; + app.session.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), view).unwrap(); + frame(&mut app, press(Key::ArrowUp, Modifiers::SHIFT)); + assert_eq!(shift(&app), 10.0, "Shift+Up steps by ten"); + frame(&mut app, press(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(shift(&app), 11.0, "Up steps by one"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y), view).unwrap(); + assert_eq!(shift(&app), 11.0); +} + /// #5218: the status bar navigator's four buttons are commands of their own, listed in Edit › /// Keyboard Shortcuts without a default key, so a user can give them one (Page Up and Page Down /// here) and step through the artboards from the keyboard. diff --git a/docs/mcp.md b/docs/mcp.md index aeed51157a..7053f0ede9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8626,6 +8626,23 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p {"name":"run_command","arguments":{"command":"object.blend.spine.moveAnchor","params":{"id":40,"anchor":1,"x":150,"y":20}}} ``` +`object.blend.setEasing {id?, easingType?, ramp?, colorShift?}` sets one blend's easing and color shift (the +first selected blend without `id`) and returns `{id, easingType, ramp, colorShift, changed}`; omitted values are kept. +`ramp` is 0 to 100 and `colorShift` -100 to 100. `easingType` and `ramp` need a blend whose steps follow a spine (a +replaced or edited spine, or `orientation: "path"`) and not made without one (`createSpine: "never"`), as the Blend +panel's Type and Ramp do. A value out of range, a hidden or locked blend and a target that is not a blend are errors, +and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, +the Selection and Blend tools show the same two values as widgets +16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that +spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets +`colorShift` and the lower one `ramp`, by one per screen pixel dragged along the spine and by one per arrow key +during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool +during it puts the value back: + +```json +{"name":"run_command","arguments":{"command":"object.blend.setEasing","params":{"id":40,"ramp":35}}} +``` + How blends interpolate: closed shapes start where they twist least unless `starts` picks the anchors; strokes interpolate weight, dashes (a solid stroke counts as the dashed one with its gaps closed), miter limit, arrowhead scale and width profiles, while caps, joins, alignment, arrowheads and brushes switch halfway; gradients of one kind with diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5e0cf03a3a..44f03c5a55 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1680,6 +1680,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. +- **Blend widgets on the canvas:** with one blend selected, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow a spine. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. + - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. - **Blend and envelope options given as text:** `object.blend.make` and `object.blend.options` (`smooth`), `object.blend.spine.moveAnchor` (`independent`) and `reshapeSegment` (`perpendicular`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`horizontal`), `object.envelope.resetWithMesh` (`maintainShape`), `object.envelope.options` (`antiAlias`, `distortAppearance`, `distortLinearGradients`, `distortPatternFills`) and `object.envelope.editContents` (`editing`) refuse a value other than true, false or null, such as `"false"`, and leave the document and its undo history as they were. From e7f260184755da69d2372d2907fc39c9cf53c2f6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 00:54:15 -0800 Subject: [PATCH 03/35] Blend: arrow keys step from the widget's shown value; a double-click on a widget leaves the art alone - After a drag past either end of the range, the first arrow key now steps back from the value shown instead of doing nothing. - With the Selection tool, a plain double-click on a blend widget no longer edits or isolates the artwork under it, or leaves isolation. Ctrl/Cmd+Shift double-click still releases a guide there. - docs/mcp.md says the lower widget shows only when the steps follow the spine, and the G13 blend easing entry in docs/gaps.md no longer lists on-canvas easing handles as missing. --- crates/tools/src/blendwidget.rs | 40 +++++++++++++++++++++++++++++---- crates/tools/src/select.rs | 5 +++++ docs/gaps.md | 5 +++-- docs/mcp.md | 4 ++-- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs index 634e2639a1..af1194d78b 100644 --- a/crates/tools/src/blendwidget.rs +++ b/crates/tools/src/blendwidget.rs @@ -239,11 +239,11 @@ impl BlendWidgetDrag { /// An arrow key during the drag: one point more (`more`) or less, ten with Shift. pub fn step(&mut self, cx: &ToolContext, more: bool, big: bool) -> Vec { let by = if big { 10.0 } else { 1.0 }; - self.keys += if more { by } else { -by }; - // Past either end, the keys stop adding: the way back starts at once. + // The step goes from the value shown, so past either end (by keys or by the drag) the way + // back starts at once. let (lo, hi) = self.widget.control.range(); - let free = self.dragged(cx); - self.keys = self.keys.clamp(lo - free, hi - free); + let stepped = (self.value_now(cx) + if more { by } else { -by }).clamp(lo, hi); + self.keys = stepped - self.dragged(cx); self.preview(cx) } @@ -533,4 +533,36 @@ mod tests { assert!(!t.busy() && !t.transforming()); assert!(t.pointer(&c, &ev(PointerKind::Up, at)).is_empty(), "the release commits nothing"); } + + /// A double-click on a widget with the Selection tool does nothing to the artwork under it. + #[test] + fn a_double_click_on_a_widget_leaves_the_artwork_under_it_alone() { + let (mut d, _, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + cover(&mut d, 190.0, 206.0, 210.0, 226.0); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 216.0); + assert!(over_widget(&c, at)); + let mut t = SelectionTool::default(); + assert!(t.pointer(&c, &ev(PointerKind::DoubleClick, at)).is_empty()); + // Beside the widget, the double-click reaches the artwork. + assert!(!t.pointer(&c, &ev(PointerKind::DoubleClick, Point::new(192.0, 224.0))).is_empty()); + } + + /// After a drag past either end, the first arrow key steps back from the value shown. + #[test] + fn an_arrow_key_after_a_drag_past_an_end_steps_from_the_value_shown() { + let (d, id, s) = blend_doc(&[(100.0, 200.0), (300.0, 200.0)], eased()); + let p = paint(); + let c = cx(&d, &s, &p); + let at = Point::new(200.0, 216.0); + let ramp = |v: f64| vec![Action::Preview("object.blend.setEasing".into(), json!({"id": id.0, "ramp": v}))]; + for (moved, back, shown) in [(200.0, ToolKey::Left, 99.0), (-200.0, ToolKey::Right, 1.0)] { + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, at)); + t.pointer(&c, &ev(PointerKind::Drag, at + Vec2::new(moved, 0.0))); + assert_eq!(t.key(&c, back, Mods::default()), ramp(shown), "{moved}"); + assert_eq!(t.pointer(&c, &ev(PointerKind::Up, at + Vec2::new(moved, 0.0))), vec![Action::Commit]); + } + } } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 84a10d350f..a0c7e43d48 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -1531,6 +1531,11 @@ impl SelectionTool { if let Some(a) = release_guide_at(cx, p, m) { return vec![a]; } + // A selected blend's widget takes a plain double-click: the artwork under it is neither + // edited nor isolated. + if blendwidget::over_widget(cx, p) { + return vec![]; + } if crate::rulerguide::guide_at(cx, p).is_some() { return vec![]; } diff --git a/docs/gaps.md b/docs/gaps.md index 4186fa64dd..1c3b39a664 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -534,8 +534,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi (#1676). A linked image is refused, as the public documentation has it. - **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. - The original polynomial distributions approximate the public description. On-canvas easing handles - and their synchronization remain missing. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + The original polynomial distributions approximate the public description. With the Selection or + Blend tool, on-canvas widgets set the color Shift and, when the steps follow a spine, the Ramp; the + Blend panel shows the values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s `gradient`, swatches and the native file) draws a linear or radial gradient fill as pixels with seeded noise of up to a diff --git a/docs/mcp.md b/docs/mcp.md index 7053f0ede9..80fd96edc6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8632,10 +8632,10 @@ first selected blend without `id`) and returns `{id, easingType, ramp, colorShif replaced or edited spine, or `orientation: "path"`) and not made without one (`createSpine: "never"`), as the Blend panel's Type and Ramp do. A value out of range, a hidden or locked blend and a target that is not a blend are errors, and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, -the Selection and Blend tools show the same two values as widgets +the Selection and Blend tools show the same values as widgets 16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that spot, a widget moves out in 16 px steps, up to 144 px, joined to the halfway point by a line): the upper one sets -`colorShift` and the lower one `ramp`, by one per screen pixel dragged along the spine and by one per arrow key +`colorShift` and the lower one, shown only when the steps follow the spine as `ramp` needs, sets `ramp`, by one per screen pixel dragged along the spine and by one per arrow key during the drag (ten with Shift). Each drag is one undo step, and `press_key` Escape or a switch to another tool during it puts the value back: From 8cd40e8216519a1a3d130114394b7faa80f23202 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:30:06 -0800 Subject: [PATCH 04/35] Docs: blend widgets show only on a blend with a spine A blend made without a spine shows neither widget; docs/gaps.md and docs/target-app-parity.md now say so next to the Ramp widget's condition. --- docs/gaps.md | 5 +++-- docs/target-app-parity.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/gaps.md b/docs/gaps.md index 1c3b39a664..3b76f723c2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -535,8 +535,9 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Partial:** blend easing (#1124, 30.7): Window › Blend has Type (Ease In, Ease Out, Ease In Out, Ease Out In), Ramp 0–100% and independent color Shift; easing is disabled without a spine. The original polynomial distributions approximate the public description. With the Selection or - Blend tool, on-canvas widgets set the color Shift and, when the steps follow a spine, the Ramp; the - Blend panel shows the values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). + Blend tool, on-canvas widgets on a blend with a spine set the color Shift and, when the steps follow + that spine, the Ramp; a blend made without a spine shows neither widget. The Blend panel shows the + values they set. [Public source](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). - **Done:** gradient Dither (30.0): the Gradient panel's Dither box (`paint.editGradient` `dither`, also in `paint.setFill`'s `gradient`, swatches and the native file) draws a linear or radial gradient fill as pixels with seeded noise of up to a diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 44f03c5a55..ce9125ff02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1680,7 +1680,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend, mesh and envelope points by number:** `object.blend.spine.moveAnchor`, `reshapeSegment` and `removeAnchor`, `object.mesh.setPointColor`, `movePoint` and `deletePoint`, and `object.envelope.setMeshPoint` refuse an `anchor`, `segment` or `index` that is not a whole number, and `object.envelope.setMeshPoint` refuses a negative `index`. Specified Steps (`object.blend.make`, `object.blend.options`) and the rows and columns of `object.mesh.create`, `object.envelope.makeWithMesh` and `resetWithMesh` must be whole numbers too. -- **Blend widgets on the canvas:** with one blend selected, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow a spine. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. +- **Blend widgets on the canvas:** with one blend selected that has a spine, the Selection and Blend tools show two widgets 16 px above and below the spine's halfway point (further out, joined to that point by a line, where the boxes of three or more other objects crowd the spot), as the [Blend panel help page](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html) describes an upper widget for Appearance and a lower one for Easing. The upper one sets Shift and the lower one Ramp, keeping the Type, and the lower one shows only when the steps follow that spine; a blend made without a spine shows neither. A drag along the spine changes the value by one per screen pixel from its value at the press, the arrow keys during the drag by one (Shift: ten), and Escape or a tool switch cancels it. A drag is one undo step, or none when the value ends where it started. The placement, the drag and the keys are VectorCraft's own rules. `object.blend.setEasing` sets the same values for agents. Evidence: `blendwidget::tests`, `tests::blendopts::{set_easing_edits_one_blend_and_refuses_what_it_cannot_set, dragging_a_blend_widget_is_one_undo_step_and_a_click_or_escape_none}`, `shortcuts::tests::shift_arrows_step_a_pressed_blend_widget_by_ten`. - **Blend, mesh and envelope numbers given as text:** `object.blend.make` and `object.blend.options` (`steps`, `distance`, `value`), `object.envelope.makeWithWarp`, `resetWithWarp` and `options` (`bend`, `h`, `v`, `horizontalDistortion`, `verticalDistortion`), `object.envelope.makeWithMesh` and `resetWithMesh` (`rows`, `cols`, `columns`), `object.envelope.options` (`fidelity`), `object.mesh.create` (`rows`, `cols`, `columns`, `highlight`) and `object.mesh.setPointColor` (`opacity`) refuse a value other than a number or null, such as `"8"`, and change nothing. `object.mesh.create` refuses an `at` that is not `[x, y]`. From d5572f8a6cd92c783d64c9cf9d2a8a2f56761e02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 01:36:09 -0800 Subject: [PATCH 05/35] Data Recovery: a keeps- entry in an area the same restore or discard holds keeps nothing Restore and discard hold each crashed app's area until they finish. When a later area's copies were kept by an entry in an area already held, the check took a second lock on that area, found it taken, and counted the area as running, so its copies were listed but neither restored nor discarded. Areas the command already holds now count as gone in that check. --- crates/engine/src/cmd/recovery.rs | 24 ++++++++++++++---------- crates/engine/src/tests/recovery.rs | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index fce76bcc27..6add9cd7be 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -1253,15 +1253,16 @@ enum Owner { /// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so /// another app leaves it alone meanwhile. In a store without locks an area is gone only when /// nothing says its app runs: no announcement (or the store can't tell) and no heartbeat for three -/// intervals but the one this session wrote taking it over. -fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool) -> Owner { +/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the +/// caller already holds. +fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec, take: bool, held: &[&str]) -> Owner { if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) { return Owner::Mine; } match store.lock(area) { Ok(Lock::Held(h)) => { // Copies an app left here when it had to leave the area stay its own while it runs. - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } hold.push(h); @@ -1272,7 +1273,7 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) { return Owner::Running; } - if let Some(who) = keeper(s, store, area) { + if let Some(who) = keeper(s, store, area, held) { return who; } // Best effort: without it another app might offer the copies too, which loses nothing. @@ -1288,12 +1289,14 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec< /// `/keeps-` ([`mark_left`]): this session, or another app that is running. `None`: /// no app that runs keeps them. An entry counts only in an area this account can lock or tell is /// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be -/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing). -fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option { +/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds +/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now +/// keeps them (a copy kept loses nothing). +fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option { let Ok(names) = store.list() else { return Some(Owner::Running) }; let mark = format!("/{KEEPS}{area}"); let mut who = None; - for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) { + for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) { match holder(s, store, k) { Owner::Mine => return Some(Owner::Mine), Owner::Running => who = Some(Owner::Running), @@ -1355,7 +1358,7 @@ fn list(s: &mut Session, _: &Value) -> Result { let mut rows = vec![]; for area in store.areas().map_err(EngineError::Other)? { let mut hold = vec![]; - let who = owner(s, store.as_ref(), &area, &mut hold, false); + let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]); let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect(); for f in &files { let meta = read_meta(store.as_ref(), f); @@ -1395,10 +1398,11 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) -> } let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c); let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect(); - let mut out = vec![]; + let mut out: Vec = vec![]; for area in areas { let mut hold = vec![]; - match (owner(s, store, area, &mut hold, true), file) { + let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect(); + match (owner(s, store, area, &mut hold, true, &held), file) { (Owner::Gone, _) => { let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect(); out.push((area.to_string(), files, hold)); diff --git a/crates/engine/src/tests/recovery.rs b/crates/engine/src/tests/recovery.rs index 8248f12a48..95387b4054 100644 --- a/crates/engine/src/tests/recovery.rs +++ b/crates/engine/src/tests/recovery.rs @@ -1179,6 +1179,31 @@ fn a_keeps_entry_in_a_running_apps_area_keeps_the_copies() { assert_eq!(offered(&mut b).len(), 2); } +/// An entry `/keeps-` in a crashed app's area keeps nothing while restore or +/// discard holds that area: both crashed apps' copies are restored, and discarded. +#[test] +fn a_keeps_entry_in_a_crashed_apps_area_held_by_the_same_command_keeps_nothing() { + let crashed = |store: &Arc| { + let mut s = session_in(store); + s.execute("file.recovery.save", &json!({})).unwrap(); + s.recovery.own_area().unwrap().to_string() + }; + for cmd in ["file.recovery.restore", "file.recovery.discard"] { + let store = Arc::new(MemoryStore::default()); + let a = crashed(&store); + let c = crashed(&store); + assert_ne!(a, c); + // The area that sorts first, which the command holds first, keeps the other one's copies. + let (first, second) = if a < c { (a, c) } else { (c, a) }; + store.write(&format!("{first}/keeps-{second}"), b"").unwrap(); + let mut b = relaunch(&store); + assert_eq!(offered(&mut b).len(), 2); + let r = b.execute(cmd, &json!({})).unwrap(); + let done = if cmd == "file.recovery.restore" { restored(r) } else { r["discarded"].as_array().unwrap().len() }; + assert_eq!(done, 2, "{cmd}"); + } +} + /// An entry `/keeps-` in a folder whose lock file can't be opened keeps nothing. #[cfg(unix)] #[test] From a0902005f7273da9c3e280e8527648b59cdacbc0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:00:56 -0800 Subject: [PATCH 06/35] Blend: the setEasing docs list the easing types and say when a blend made without a spine takes easing docs/mcp.md lists the four easingType values. It and the object.blend.setEasing params text say that a blend made without a spine takes easingType and ramp once Replace Spine has given it one, which the set_easing test now checks. --- crates/engine/src/cmd/live/commands.rs | 2 +- crates/engine/src/tests/blendopts.rs | 6 ++++++ docs/mcp.md | 7 ++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 4cbc5e130f..980983519f 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { "Blend Easing", [], None, - "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path, on a blend not made without a spine); out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", + "{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path); a blend made without a spine takes them once Replace Spine has given it one; out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}", has_doc, blend_set_easing ), diff --git a/crates/engine/src/tests/blendopts.rs b/crates/engine/src/tests/blendopts.rs index a71e835108..93f531d1e8 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -481,6 +481,12 @@ fn set_easing_edits_one_blend_and_refuses_what_it_cannot_set() { assert_eq!(depth(&s), d3); s.execute("object.blend.setEasing", &json!({"id": n.0, "colorShift": 10})).unwrap(); assert_eq!(spec(&s, n).1.color_shift, 10.0); + // Replace Spine gives it a spine, and it takes easing from then on. + let path = id_of(&s.execute("path.create", &json!({"d": "M0 320 L200 320"})).unwrap()); + sel(&mut s, &[n, path]); + s.execute("object.blend.replaceSpine", &json!({})).unwrap(); + let r = s.execute("object.blend.setEasing", &json!({"id": n.0, "easingType": "easeOut", "ramp": 30})).unwrap(); + assert_eq!((&r["easingType"], &r["ramp"], &r["changed"]), (&json!("easeOut"), &json!(30.0), &json!(true))); } /// Dragging a blend widget with the Selection tool or the Blend tool is one undo step; a click, a diff --git a/docs/mcp.md b/docs/mcp.md index 2e4536c069..05ec24d69e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8754,9 +8754,10 @@ so its point at `t` moves by (dx, dy), turning a straight spine into a curve (`p `object.blend.setEasing {id?, easingType?, ramp?, colorShift?}` sets one blend's easing and color shift (the first selected blend without `id`) and returns `{id, easingType, ramp, colorShift, changed}`; omitted values are kept. -`ramp` is 0 to 100 and `colorShift` -100 to 100. `easingType` and `ramp` need a blend whose steps follow a spine (a -replaced or edited spine, or `orientation: "path"`) and not made without one (`createSpine: "never"`), as the Blend -panel's Type and Ramp do. A value out of range, a hidden or locked blend and a target that is not a blend are errors, +`easingType` is `easeIn`, `easeOut`, `easeInOut` or `easeOutIn`, `ramp` is 0 to 100 and `colorShift` -100 to 100. +`easingType` and `ramp` need a blend whose steps follow a spine (a replaced or edited spine, or +`orientation: "path"`), as the Blend panel's Type and Ramp do. A blend made without a spine +(`createSpine: "never"`) takes them once Replace Spine has given it one. A value out of range, a hidden or locked blend and a target that is not a blend are errors, and a call that changes nothing records no undo step (`changed: false`). With one blend selected that has a spine, the Selection and Blend tools show the same values as widgets 16 px above and below the spine's halfway point (where the boxes of three or more other visible objects hold that From baa7ada7a13df33fccec8f7151be6e4dcc862f67 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 06:05:56 -0800 Subject: [PATCH 07/35] Automation: errors name a path outside the read roots by its file name Under --automation-read-root, an error about a file or folder the client did not give now names it whole only when it is inside the read roots. Outside them it names the file or folder by its file name, or as "a file or folder outside the read roots" when it has none, and keeps the roots and the reason. A path the client gives stays whole. This covers a save without a path (the document's own file and the files written beside it, also inside action.batch, and the refusal to write over a file read with losses), file.revert, script.listInstalled, plugin.reload without a path and plugin.list's folder report, and links.relink with copy (the document's folder, its error when the document's path has no folder, and the copies in the reply). Export for Screens' refusal for an export preset's folder uses the same helper. --- crates/engine/src/cmd/fileio.rs | 3 +- crates/engine/src/cmd/fileio/access.rs | 89 ++++++++++++++++++++ crates/engine/src/cmd/fileio/action_batch.rs | 36 ++++++++ crates/engine/src/cmd/fileio/save.rs | 14 ++- crates/engine/src/cmd/fileio/screens.rs | 42 +-------- crates/engine/src/cmd/links.rs | 26 ++++-- crates/engine/src/cmd/plugin.rs | 43 ++++++++-- crates/engine/src/cmd/scripts.rs | 3 +- crates/engine/src/tests/links.rs | 50 +++++++++++ crates/engine/src/tests/plugins.rs | 31 +++++++ crates/engine/src/tests/save.rs | 49 +++++++++++ crates/engine/src/tests/script_folder.rs | 4 +- docs/mcp.md | 6 ++ docs/target-app-parity.md | 1 + 14 files changed, 342 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 329988cb34..e7dc59a951 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -127,7 +127,8 @@ pub use access::write_file_with; #[cfg(not(target_arch = "wasm32"))] pub(crate) use access::{Placed, copy_new, file_metadata}; pub(crate) use access::{ - absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file, + absolute_path, create_dir, document_path, file_created, file_stamp, naming_in_error, read_file, read_file_head, shown_link, shown_path, + write_file, write_new_file, }; #[cfg(all(test, windows))] pub(crate) use access::{document_path_on, document_path_with}; diff --git a/crates/engine/src/cmd/fileio/access.rs b/crates/engine/src/cmd/fileio/access.rs index 7b20326106..14251dee28 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -65,6 +65,50 @@ pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::bo std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name }) } +/// What an error names a file or folder outside the read roots by when [`shown_path`] gives no +/// name for it, or gives one that is not valid text. +pub(crate) const UNNAMED: &str = "a file or folder outside the read roots"; + +/// An error about `path`, a file or folder the client did not give, as the request being served +/// may see it: where the message names `path` ([`naming_in_message`]), it names it as +/// [`shown_path`] does, or as [`UNNAMED`] when that is empty or holds a replacement character. The +/// roots the message names and the reason stay whole. Unchanged when `path` is [`shown`]. +pub(crate) fn naming_in_error(e: EngineError, path: &str) -> EngineError { + if path.is_empty() || shown(path) { + return e; + } + let name = shown_path(path); + let name = if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED } else { &name }; + match e { + EngineError::Other(msg) => EngineError::Other(naming_in_message(&msg, path, name)), + EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: naming_in_message(&msg, path, name) }, + e => e, + } +} + +/// `msg` with `path` given as `name` where it begins a path the message is about: at the start of +/// `msg` (`: `), after a `: ` (`... outside the write root : `) or after +/// `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message names +/// stays whole, even one inside `path`. +pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String { + const BEFORE: [&str; 2] = [": ", "cannot resolve "]; + let mut out = String::with_capacity(msg.len()); + let mut rest = msg; + loop { + if let Some(after) = rest.strip_prefix(path) + && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) + { + out.push_str(name); + rest = after; + } + let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; + out.push_str(&rest[..i + len]); + rest = &rest[i + len..]; + } + out.push_str(rest); + out +} + /// `path` checked for writing against the automation roots in force. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn may_write(path: &str) -> Result<()> { @@ -441,3 +485,48 @@ pub(crate) fn create_dir(path: &str) -> Result<()> { pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> { Err(no_fs(path)) } + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use crate::file_access::{AutomationRoots, confine}; + + /// An error about a path the client did not give, under roots that don't let it be read: the + /// path is named by its file name (after a `/` or a `\`), or as [`UNNAMED`] when it ends in no + /// name or its name is not valid text, and every root and the reason stay whole. A path inside the + /// read roots, and any path without roots in force, stay whole. + #[test] + fn an_error_names_a_path_outside_the_read_roots_by_its_name() { + let base = std::env::temp_dir().join(format!("vc-access-naming-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (a, b, out) = (base.join("a"), base.join("b"), base.join("kept-out")); + for f in [&a, &b, &out] { + std::fs::create_dir_all(f).unwrap(); + } + let text = |p: &std::path::Path| p.to_string_lossy().into_owned(); + let roots = AutomationRoots::new(&[a.as_path(), b.as_path()], &[a.as_path(), b.as_path()]).unwrap(); + let file = text(&out.join("doc.svg")); + let raw = confine(roots.as_ref(), || crate::file_access::check_write(&file)).unwrap_err(); + let shown_root = |p: &std::path::Path| crate::cmd::findfiles::plain(std::fs::canonicalize(p).unwrap()).display().to_string(); + let (ra, rb) = (shown_root(&a), shown_root(&b)); + let named = |e: EngineError, path: &str| confine(roots.as_ref(), || naming_in_error(e, path)).to_string(); + + assert_eq!(named(EngineError::Other(raw.clone()), &file), format!("automation path rejected: outside the write roots {ra}, {rb}: doc.svg")); + let io = named(EngineError::Other(format!("{file}: disk full")), &file); + assert_eq!(io, "doc.svg: disk full"); + let bad = named(EngineError::BadParams { cmd: "x".into(), msg: format!("{file}: not a file") }, &file); + assert_eq!(bad, "invalid parameters for `x`: doc.svg: not a file"); + // A Windows path written with `\`, a path that ends in no name and a name that isn't text. + let win = r"C:\Users\me\secret\doc.svg"; + assert_eq!(named(EngineError::Other(format!("{win}: denied")), win), "doc.svg: denied"); + let folder = format!("{}/", text(&out)); + assert_eq!(named(EngineError::Other(format!("{folder}: denied")), &folder), format!("{UNNAMED}: denied")); + let lossy = text(&out.join("bad\u{FFFD}.svg")); + assert_eq!(named(EngineError::Other(format!("{lossy}: denied")), &lossy), format!("{UNNAMED}: denied")); + // Readable, or nothing confined: whole. + let inside = text(&a.join("x.svg")); + assert_eq!(named(EngineError::Other(format!("{inside}: denied")), &inside), format!("{inside}: denied")); + assert_eq!(naming_in_error(EngineError::Other(raw.clone()), &file).to_string(), raw); + let _ = std::fs::remove_dir_all(base); + } +} diff --git a/crates/engine/src/cmd/fileio/action_batch.rs b/crates/engine/src/cmd/fileio/action_batch.rs index 4953785189..232d5ba1f4 100644 --- a/crates/engine/src/cmd/fileio/action_batch.rs +++ b/crates/engine/src/cmd/fileio/action_batch.rs @@ -1429,6 +1429,42 @@ mod tests { assert_eq!(written.len(), 2, "{written:?}"); } + /// A Data Sets batch whose Save step has no path saves the document's own file. When that file is + /// outside the client's read and write roots, each row's error keeps the write root and names the + /// file by its file name, with Stop on Error on and off. A source folder the client gives stays + /// whole in the error. + #[test] + fn dataset_batch_step_error_hides_document_path() { + use crate::file_access::{AutomationRoots, confine}; + + let base = tmp("roots-error"); + let (inside, outside) = (base.join("in"), base.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = with_data_sets(&["Alpha", "Beta"]); + let own = outside.join("doc.vectorcraft").to_string_lossy().into_owned(); + s.execute("document.save", &json!({"path": own})).unwrap(); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + for (stop, rows) in [(true, 1), (false, 2)] { + let p = json!({"source": "datasets", "stopOnError": stop, "commands": [{"command": "document.save", "params": {}}]}); + let r = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap(); + assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}"); + for row in r["processed"].as_array().unwrap() { + let e = row["error"].as_str().unwrap_or_default(); + let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft"); + assert_eq!(e, want, "{r}"); + } + assert!(!r.to_string().contains(&hidden), "{r}"); + } + let p = json!({"source": "folder", "folder": hidden, "commands": [{"command": "object.unlockAll", "params": {}}]}); + let e = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap_err().to_string(); + assert!(e.contains(&hidden), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + /// The names of the files in `dir`, sorted. fn file_names(dir: &std::path::Path) -> Vec { let mut names: Vec = diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 5d7783665b..0fa346dd5b 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -298,7 +298,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result { }; let modified = date_param(p, "modified", cmd)?; if let Some(path) = &path { - super::check_not_lossy_overwrite(st, path, p, cmd)?; + // Without a path, it is the document's own file: the error names it as the request may see it. + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?; } let written = written_as(cmd, format, &Value::Object(options.clone())); let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string()); @@ -1043,7 +1045,12 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result let plan = save_plan(s, mode, p)?; let job = save_job(s, plan)?; let uid = s.doc()?.uid; - let mut out = job.write_streamed(&mut super::write_file_with)?; + let given = str_param(p, "path").is_some_and(|g| !g.is_empty()); + // Without a path, the files written are the document's own file and those beside it. + let mut write = |path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| { + super::write_file_with(path, fill).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) }) + }; + let mut out = job.write_streamed(&mut write)?; job.complete(s, uid); leave_out_unreadable_save_paths(&mut out, str_param(p, "path")); Ok(out) @@ -1098,7 +1105,8 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> { fn revert(s: &mut Session, _: &Value) -> Result { let index = s.active_index().ok_or(crate::EngineError::NoDocument)?; let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?; - let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); + let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?; + let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs); doc.template = false; // Back to the saved file: its recovery copy has nothing left to recover. let uid = s.doc()?.uid; diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index b48aef68c3..b991dc0bec 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -649,7 +649,7 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { let p = with_preset(s, p)?; let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) }; let mut r = export(s, &p).map_err(|e| match hidden { - Some(folder) => leave_out_folder_from_error(e, folder), + Some(folder) => super::access::naming_in_error(e, folder), None => e, })?; remember(s, &p); @@ -659,41 +659,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { Ok(r) } -/// An error of an export into `folder`, a folder outside the read roots that the client did not -/// give: `folder` as the path the error is about names it by its own name -/// ([`super::access::shown_path`]), and the rest of the message stays. -fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError { - let name = super::access::shown_path(folder); - match e { - EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, &name)), - EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, &name) }, - e => e, - } -} - -/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start -/// of `msg` (`: `), after a `: ` (`... outside the write root : `) or -/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message -/// names stays whole, even one inside `folder`. -fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String { - const BEFORE: [&str; 2] = [": ", "cannot resolve "]; - let mut out = String::with_capacity(msg.len()); - let mut rest = msg; - loop { - if let Some(after) = rest.strip_prefix(folder) - && after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' ')) - { - out.push_str(name); - rest = after; - } - let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break }; - out.push_str(&rest[..i + len]); - rest = &rest[i + len..]; - } - out.push_str(rest); - out -} - /// The reply `r` of an export into `folder`, a folder the client did not give, as the request being /// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's /// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`), @@ -1475,7 +1440,8 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result { #[cfg(test)] mod tests { - use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name}; + use super::super::access::naming_in_message; + use super::{Output, ScreenSize, safe_file_name}; /// A hidden export folder is named by its name where a message is about it, and a root that /// starts with the same text, or a longer path, stays whole. @@ -1492,7 +1458,7 @@ mod tests { ("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"), ]; for (msg, want) in cases { - assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}"); + assert_eq!(naming_in_message(msg, f, "out"), want, "{msg}"); } } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 89c0afefcf..43b7c3dfd4 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -68,7 +68,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", has_doc, relink ), @@ -1337,7 +1337,9 @@ fn relink(s: &mut Session, p: &Value) -> Result { (true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), (true, true) => { let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?; - Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?) + // The document's path is not the client's: the error names it as the request may see it. + let no_folder = || fileio::naming_in_error(bad(C, format!("{doc} has no folder")), &doc); + Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?) } }; // An image or a placed document, with its link. @@ -1472,6 +1474,12 @@ fn relink(s: &mut Session, p: &Value) -> Result { not_found.dedup(); let mut out = json!({ "relinked": relinked, "notFound": not_found }); if copy_to.is_some() { + // The copies are in the document's folder, which the client did not give. + for pair in copied.iter_mut().chain(kept.iter_mut()) { + if let Some(to) = pair.get("to").and_then(Value::as_str).map(str::to_string) { + pair["to"] = json!(fileio::shown_path(&to)); + } + } out["copied"] = json!(copied); out["kept"] = json!(kept); } @@ -1521,9 +1529,14 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo use std::io::Write as _; let source = Path::new(from); let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - // Each name it takes is checked against the automation roots in force. + // Each name it takes is checked against the automation roots in force. The folder is the + // document's, so a refusal names it and the file as the request may see them. let create = |to: &Path| { - crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?; + let to_text = to.to_string_lossy(); + crate::file_access::check_write(&to_text).map_err(|e| { + let e = fileio::naming_in_error(EngineError::Other(e), &to_text); + std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string()) + })?; vectorcraft_format::write_new_with(to, |f| f.write_all(bytes)) }; // A file there that automation may not read isn't taken for the same file. @@ -1534,7 +1547,10 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)), Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)), Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))), - Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))), + Err(e) => { + let dir = dir.to_string_lossy(); + Err(fileio::naming_in_error(EngineError::Other(format!("{dir}: {e}")), &dir)) + } } } diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 2d16bbc683..04a065ba1b 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -917,12 +917,43 @@ fn reload(s: &mut Session, p: &Value) -> Result { if folder.is_empty() { return Err(bad(C, "no folder: pass `path` or set Preferences › Plug-ins & Scratch Disks › Additional Plug-ins Folder")); } + let given = p.get("path").is_some(); let report = load_folder(s, &folder); - if let Some(e) = report.get("error").and_then(Value::as_str) { + let shown = if given { report.clone() } else { report_as_shown(report.clone()) }; + if let Some(e) = shown.get("error").and_then(Value::as_str) { return Err(EngineError::Other(e.to_string())); } - *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report.clone())); - Ok(report) + *FOLDER.lock().unwrap_or_else(|e| e.into_inner()) = Some((folder, s.plugin_dir.clone(), report)); + Ok(shown) +} + +/// The report of a plug-in folder load as the request being served may see it: the folder and each +/// file it names that is outside the read roots is named by its file name +/// ([`super::fileio::shown_path`]), also in the errors ([`super::fileio::naming_in_error`]). +fn report_as_shown(mut report: Value) -> Value { + use super::fileio::{naming_in_error, shown_path}; + + if crate::file_access::current().is_none() { + return report; + } + let naming = |msg: &str, path: &str| naming_in_error(EngineError::Other(msg.to_string()), path).to_string(); + let Some(o) = report.as_object_mut() else { return report }; + if let Some(folder) = o.get("folder").and_then(Value::as_str).map(str::to_string) { + if let Some(Value::String(e)) = o.get_mut("error") { + *e = naming(e, &folder); + } + o.insert("folder".into(), json!(shown_path(&folder))); + } + if let Some(Value::Array(rows)) = o.get_mut("failed") { + for row in rows.iter_mut().filter_map(Value::as_object_mut) { + let Some(file) = row.get("file").and_then(Value::as_str).map(str::to_string) else { continue }; + if let Some(Value::String(e)) = row.get_mut("error") { + *e = naming(e, &file); + } + row.insert("file".into(), json!(shown_path(&file))); + } + } + report } /// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`). @@ -933,7 +964,7 @@ fn describe_installed(p: &Plugin) -> Value { } fn list(_: &mut Session, _: &Value) -> Result { - let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone()); + let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| report_as_shown(r.clone())); let disabled: Vec = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect(); Ok( json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}), @@ -1055,7 +1086,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), inputs?: [\"type\"|\"image\"|\"symbolInstance\"] (the objects besides paths a filter reads, see plugin.run), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own; commands: a filter may run the commands its commandAllowlist names, see docs/plugins.md), commandAllowlist?: [command id] (the commands a filter with the commands permission may run while it runs, with vc_call), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), paintOutput?: true (a live effect whose pieces have paints of their own, see docs/plugins.md), cacheBytes (the bytes its cache holds; see plugin.clearCache), commands?: [{id, command (its command id, plugin.., which runs it), label, params (doc), paramsSchema}] (the menu commands a filter or live effect adds)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null (a folder or file outside the automation read roots is named by its file name, also in an error), safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}", always, list ), @@ -1118,7 +1149,7 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module, and one disabled with plugin.setEnabled, is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included; an error in Safe Mode; without path, a folder or file outside the automation read roots is named by its file name in the reply and in an error) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 8c86f0dfe3..772e1cdc0c 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -253,7 +253,8 @@ fn list_installed(s: &mut Session, _: &Value) -> Result { return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false})); }; let folder = root.to_string_lossy(); - crate::file_access::check_read(&folder).map_err(EngineError::Other)?; + // The Scripts folder is the app's: a refusal names it as the request may see it. + crate::file_access::check_read(&folder).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), &folder))?; let listing = script::folder::list(root); let scripts: Vec = listing .scripts() diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index f0a0674f08..a3c7a72aee 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -728,6 +728,56 @@ fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() { assert_eq!(link_path(&s, a), one); } +/// With copy, for a client whose read roots don't hold the document's folder: the reply names the +/// copy by its file name, and a copy refused there keeps the write root and names the folder and +/// the file by their names. +#[test] +fn relink_with_copy_names_the_documents_folder_by_its_name_outside_the_read_roots() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-roots"); + let one = dir.file("doc/one.png"); + write(&one, &png(60, 30, RED)); + let mut s = session(); + let a = place(&mut s, &one); + save(&mut s, &dir.file("doc/poster.vectorcraft")); + let x = dir.file("new/x.png"); + write(&x, &png(60, 30, BLUE)); + let (new, doc) = (Path::new(&x).parent().unwrap().to_path_buf(), Path::new(&one).parent().unwrap().to_path_buf()); + let hidden = doc.to_string_lossy().into_owned(); + let p = json!({"ids": [a.0], "path": x, "copy": true}); + + let writes_there = AutomationRoots::new(&[new.as_path()], &[doc.as_path()]).unwrap(); + let r = confine(writes_there.as_ref(), || s.execute("links.relink", &p)).unwrap(); + assert_eq!(r["copied"], json!([{"from": x, "to": "x.png"}]), "{r}"); + assert!(!r.to_string().contains(&hidden), "{r}"); + std::fs::remove_file(dir.file("doc/x.png")).unwrap(); + + let elsewhere = AutomationRoots::new(&[new.as_path()], &[new.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&new).unwrap()).display().to_string(); + let e = confine(elsewhere.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, format!("doc: automation path rejected: outside the write root {root}: x.png")); + assert!(!Path::new(&dir.file("doc/x.png")).exists()); +} + +/// With copy, for a document whose path has no folder: outside the read roots the error does not +/// name the path, and without roots it names it whole. +#[test] +fn relink_with_copy_names_a_document_path_without_a_folder_as_the_request_may_see_it() { + use crate::file_access::{AutomationRoots, confine}; + + let dir = Folder::new("relink-copy-no-folder"); + let mut s = session(); + s.doc_mut().unwrap().path = Some("/".into()); + let p = json!({"path": dir.file("x.png"), "copy": true}); + let roots = AutomationRoots::new(&[dir.0.as_path()], &[dir.0.as_path()]).unwrap(); + let e = confine(roots.as_ref(), || s.execute("links.relink", &p)).unwrap_err().to_string(); + assert_eq!(e, "invalid parameters for `links.relink`: a file or folder outside the read roots has no folder"); + let whole = s.execute("links.relink", &p).unwrap_err().to_string(); + let doc = crate::cmd::fileio::absolute_path("/"); + assert_eq!(whole, format!("invalid parameters for `links.relink`: {doc} has no folder")); +} + /// A scope or paths that doesn't fit is refused, and nothing is relinked. #[test] fn relink_refuses_scopes_and_paths_that_dont_fit() { diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index a1e615a08d..52820be60f 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1433,6 +1433,37 @@ fn plugins_folder_set_again_after_clearing_installs_its_plugins() { run(&mut s, "plugin.remove", json!({"id": id})); } +/// The Additional Plug-ins Folder, seen by a client whose read roots don't hold it: `plugin.list` +/// names the folder and the files that failed by their names, and `plugin.reload` without a path is +/// refused naming the folder by its name and keeping the root. A folder the client gives stays whole. +#[test] +fn a_plugins_folder_outside_the_read_roots_is_named_by_its_name() { + use crate::file_access::{AutomationRoots, confine}; + + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); + let dir = crate::tests_links::Folder::new("plugin-folder-hidden"); + let work = crate::tests_links::Folder::new("plugin-folder-work"); + std::fs::write(dir.0.join("broken.wasm"), b"\0asm?").unwrap(); + let mut s = session(); + let shown = dir.0.display().to_string(); + let name = dir.0.file_name().unwrap().to_string_lossy().into_owned(); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": shown})); + let roots = AutomationRoots::new(&[work.0.as_path()], &[]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work.0).unwrap()).display().to_string(); + + let list = confine(roots.as_ref(), || run(&mut s, "plugin.list", json!({}))); + assert_eq!(list["folder"]["folder"], json!(name), "{list}"); + assert_eq!(list["folder"]["failed"][0]["file"], json!("broken.wasm"), "{list}"); + assert!(!list.to_string().contains(&shown), "{list}"); + let e = confine(roots.as_ref(), || s.execute("plugin.reload", &json!({}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: {name}")); + let e = confine(roots.as_ref(), || s.execute("plugin.reload", &json!({"path": shown}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: {shown}")); + // Unconfined, the report names the folder whole. + assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["folder"], json!(shown)); + run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": ""})); +} + /// #2541: a plug-in installed in a session with a plug-in store is installed again in the next /// session that attaches the store, and Remove Plug-in takes it out of the store. #[test] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 9932055652..8d557bb4ff 100644 --- a/crates/engine/src/tests/save.rs +++ b/crates/engine/src/tests/save.rs @@ -84,6 +84,55 @@ fn a_confined_save_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(d); } +/// A save without a path, and Revert, refused because the document's own file is outside a confined +/// client's roots: the error keeps the root and the reason and names the file by its file name, also +/// for the files a save writes beside it. A path the client gives stays whole in the error. +#[test] +fn pathless_save_rejection_hides_document_folder() { + use crate::file_access::{AutomationRoots, confine}; + + let d = dir("roots-error"); + let (inside, outside) = (d.join("in"), d.join("kept-out")); + for f in [&inside, &outside] { + std::fs::create_dir_all(f).unwrap(); + } + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap(); + rect(&mut s); + let own = path(&outside, "doc.vectorcraft"); + s.execute("document.save", &json!({"path": own})).unwrap(); + rect(&mut s); + let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap(); + let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string(); + let hidden = outside.to_string_lossy().into_owned(); + let mut refused = |id: &str, p: Value| confine(roots.as_ref(), || s.execute(id, &p)).unwrap_err().to_string(); + + let e = refused("document.save", json!({})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + let e = refused("document.save", json!({"separateArtboards": true})); + assert!(e.starts_with(&format!("automation path rejected: outside the write root {root}: ")) && !e.contains(&hidden), "{e}"); + let e = refused("file.revert", json!({})); + assert_eq!(e, format!("automation path rejected: outside the read root {root}: doc.vectorcraft")); + // Given by the client: whole. + let e = refused("document.save", json!({"path": own})); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: {own}")); + // Without read roots, a write refusal still names the file by its file name. + let write_only = AutomationRoots::new(&[], &[inside.as_path()]).unwrap(); + let e = confine(write_only.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert_eq!(e, format!("automation path rejected: outside the write root {root}: doc.vectorcraft")); + assert!(s.doc().unwrap().is_dirty(), "nothing was saved"); + // Read with losses from its own file: the refusal to write over it names the file by its file name. + let st = s.doc_mut().unwrap(); + st.imported_from = Some(own.clone()); + st.import_losses = vec!["hidden text".into()]; + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({}))).unwrap_err().to_string(); + assert!(e.starts_with("invalid parameters for `document.save`: doc.vectorcraft is the file this document was read from"), "{e}"); + assert!(!e.contains(&hidden), "{e}"); + let e = confine(roots.as_ref(), || s.execute("document.save", &json!({"path": own}))).unwrap_err().to_string(); + assert!(e.contains(&format!("{own} is the file this document was read from")), "{e}"); + let _ = std::fs::remove_dir_all(d); +} + #[test] fn an_svg_path_writes_svg_and_save_writes_it_back() { let d = dir("svg"); diff --git a/crates/engine/src/tests/script_folder.rs b/crates/engine/src/tests/script_folder.rs index b176b4f143..312db3e0fc 100644 --- a/crates/engine/src/tests/script_folder.rs +++ b/crates/engine/src/tests/script_folder.rs @@ -311,7 +311,9 @@ fn the_automation_read_root_keeps_an_agent_out_of_the_scripts_folder() { s.attach_scripts_dir(root.clone()); let roots = crate::file_access::AutomationRoots::new(&[&work], &[]).unwrap().unwrap(); let e = crate::file_access::confine(Some(&roots), || s.execute("script.listInstalled", &json!({}))).unwrap_err(); - assert!(e.to_string().contains("outside the read root"), "{e}"); + // The Scripts folder is the app's: the error keeps the root and names the folder by its name. + let shown_root = crate::cmd::findfiles::plain(std::fs::canonicalize(&work).unwrap()).display().to_string(); + assert_eq!(e.to_string(), format!("automation path rejected: outside the read root {shown_root}: Scripts")); let e = crate::file_access::confine(Some(&roots), || s.installed_script(&script.to_string_lossy())).unwrap_err(); assert!(e.to_string().contains("outside the read root"), "{e}"); // A read root that holds the folder lets both through. diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..4931fed70d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -202,6 +202,12 @@ the suite) keep the agent to the folders you give it: `outside the write roots /work/project/out, /tmp/scratch: …`) or `automation filesystem access is not granted: write authority is absent: /work/project/out/x.png`. Links in an opened document that point outside the read roots show as missing. +- An error names a path the client gave as given. A path the client did not give (the document's own file + in a save without `path` and in `file.revert`, also inside `action.batch`, an export preset's folder, the + Scripts folder, the Additional Plug-ins Folder, the document's folder in `links.relink {copy}`) is named + whole only inside the read roots; outside them the error names it by its file name, or as + `a file or folder outside the read roots` when it has none, and keeps the roots and the reason, for example + `automation path rejected: outside the write root /work/project/out: poster.vectorcraft`. - The preferences that move folders the app reads or writes on its own (`fontsFolder`, `pluginsFolder`, `recoveryFolder`, `templatesFolder`) can only be set inside the roots. The app's own folders (preferences, Data Recovery, the User Defined library folders, VectorCraft's Fonts folder) are not diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..ef1059c692 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -764,6 +764,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Scripts folder: File › Scripts lists the `.js` and `.jsx` files of the `Scripts` folder next to the preferences, each named after its file without the extension, with each subfolder as a submenu, above Check Script… and Other Script… (https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/install-and-run-scripts.html). VectorCraft's own rules: the folder is read again when the window becomes active and when the File menu opens (in the macOS menu bar, when any of its menus opens, and the new listing shows the next time the menu opens), links are not followed, and one read stops at 2,048 entries, 512 scripts, 128 subfolders, 4 levels or 250 ms. `script.listInstalled` returns the listing. `crates/engine/src/tests/script_folder.rs`, `crates/ui-egui/src/tests/scripts_menu.rs`. - Open Recent Files under `--automation-read-root`: the control channel's `ui.menu.list` and `file.recentFiles` leave out the recent files outside the read roots, the other rows keep their ids, and `file.openRecentN` for one outside them answers `no such recent file`, as an empty slot does. `crates/ui-egui/src/tests/automation.rs`. - Saves, Export for Screens and Package under `--automation-read-root`: a save without a path leaves the document's own file out of its reply, and a save without a path known leaves out the suggested folder, when they are outside the read roots; Export for Screens into an export preset's folder outside them lists its files relative to that folder and names that folder by its name in an error, and the export settings and saved presets leave out a folder outside them when read or exported; Package's report and warnings, and Document Info's linked images, name a file outside them by its file name. A path the client gives stays in the reply. `crates/engine/src/tests/save.rs`, `crates/engine/src/cmd/fileio/tests_screens.rs`, `crates/engine/src/tests/package.rs`, `crates/ui-egui/src/tests/automation.rs`. + - Errors under `--automation-read-root`: a refused save without a path and `file.revert` (also as an `action.batch` step), `script.listInstalled`, `plugin.reload` without a path and `links.relink {copy}` name the document's own file, the Scripts folder, the Additional Plug-ins Folder or the document's folder by its file name when it is outside the read roots, and keep the roots and the reason; `plugin.list`'s folder report and `links.relink`'s copies name such a folder or file by its file name too. A path the client gives stays whole. `crates/engine/src/tests/save.rs` (`pathless_save_rejection_hides_document_folder`), `crates/engine/src/cmd/fileio/action_batch.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/links.rs`, `crates/engine/src/tests/script_folder.rs`. - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. From ec9a9fd3c9a54e2e63c1dfff359ca82c7b1466ab Mon Sep 17 00:00:00 2001 From: makesomethingshit Date: Thu, 12 Nov 2026 23:44:54 +0900 Subject: [PATCH 08/35] Fix #6865: trust oversized editing artboards with capped PDF pages --- crates/eps/src/import/native/build.rs | 33 ++++++++++++++++++++++++++- crates/eps/src/import/tests_native.rs | 18 +++++++++++++++ docs/file-format-parity.md | 6 ++++- docs/gaps.md | 4 +++- docs/roadmap.md | 9 ++++++-- docs/target-app-parity.md | 8 +++++-- 6 files changed, 71 insertions(+), 7 deletions(-) diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index c86cdb9516..c30086ee68 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -191,6 +191,36 @@ fn left_out_of_comparisons(page: Page, mut restyled: Vec, placeholders: Ve restyled } +/// A single PDF page capped at the format's 14,400-unit limit, without user-space scaling. +/// The other dimension must still match; unrelated page-size mismatches remain errors. +fn capped_pdf_page(doc: &Document, visible: &Document, board: Rect, page: Rect, unit: f64) -> bool { + let capped = |side: f64, pdf: f64| (pdf - 14_400.0).abs() <= 0.5 && side > pdf + 0.5; + let matching = |side: f64, pdf: f64| (side - pdf).abs() <= 0.5 || capped(side, pdf); + unit == 1.0 + && doc.artboards.len() == 1 + && visible.artboards.len() == 1 + && (capped(board.width(), page.width()) || capped(board.height(), page.height())) + && matching(board.width(), page.width()) + && matching(board.height(), page.height()) +} +/// The PDF-covered area of a trusted oversized artboard; its full artboard stays in the document. +fn compared_page_rect( + doc: &Document, + visible: &Document, + board: Rect, + pdf: Rect, + trusted_page: Option, + unit: f64, + warnings: &mut Vec, +) -> Rect { + if matches!(trusted_page, Some(Page::Artboard)) && capped_pdf_page(doc, visible, board, pdf, unit) { + warnings.push("the editing data's artboard is larger than the PDF page: the full artboard comes from its editing data".into()); + Rect::new(board.x0, board.y0, board.x0 + pdf.width(), board.y0 + pdf.height()) + } else { + board + } +} + /// [`build`] from `structure`, the editing copy `data` already read. pub(super) fn build_from( structure: Structure, @@ -222,7 +252,7 @@ pub(super) fn build_from( bindings, bindings_skipped, warped_type, - warnings, + mut warnings, } = structure; let mut restyled = left_out_of_comparisons(page, restyled, placeholders); // Editing data in the units of the page's user space ([`in_units`]), or at its declared large @@ -270,6 +300,7 @@ pub(super) fn build_from( { (rect, cropped) = (art, true); } + rect = compared_page_rect(&doc, visible, rect, page_rect, trust.then_some(page), unit, &mut warnings); if !fits(&rect) { // Both sizes in points as the fields show them, and the editing data's page to the // file's as a scale (1:10 is a tenth of its size) when its sides are in proportion and diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 7353c5c190..74e2c34e3f 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -6940,3 +6940,21 @@ fn text_a_placed_file_covers_only_at_its_first_placement_is_kept() { assert_eq!(text_of(&d.layers[0]).iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); assert!(!w.iter().any(|w| w.contains("doesn't draw")), "{w:?}"); } + +/// #6865: explicitly trusting editing data keeps an artboard larger than its PDF page. +#[test] +fn trusted_editing_data_keeps_an_artboard_taller_than_its_pdf_page() { + let art = native(&layer("Oversized", true, &square(10, 10))).replace(" 0 0 100 100", " 0 0 12992.726 14637.147"); + let (d, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, true, 1.0); + assert!(read, "{w:?}"); + assert_eq!(names(&d), ["Oversized"], "{w:?}"); + assert!((d.artboards[0].rect.height() - 14637.147).abs() < 0.001); + assert_eq!(kinds(&d.layers[0]), ["path"]); + assert!(w.iter().any(|w| w.contains("artboard is larger than the PDF page")), "{w:?}"); + let child = &d.layers[0].children().unwrap()[0]; + assert!(child.visual_bounds().unwrap().y0 > 14400.0, "art beyond the PDF page is kept"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12992.7, 14400.0), vec![], false, false, 1.0); + assert!(!read, "default import still rejects the size mismatch: {w:?}"); + let (_, w, read) = crate::layered_ai(&private(&art), Document::new(12000.0, 14400.0), vec![], false, true, 1.0); + assert!(!read, "the uncapped dimension must still match: {w:?}"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b99adf420e..28e2f030f9 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,8 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; newly imported SVG/SVGZ titles are filtered and bounded without changing restored titles, #3626; `.ai` and EPS import, and what every export and `.ai` save draw: type's strokes in an envelope paint at their own weight along its bent outlines, Distort Appearance on or off, #6644; `.ai` and EPS import: centred and right-aligned lines of tracked type are aligned without the tracking after their last character, as the file's page aligns them, #6610; `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art, #6570; `.ai` save and open: layer colour presets Yellow, Magenta and Cyan keep their colour, as the `Lb` colour index now follows the file's numbering, #6502; `.ai` save: stroked type keeps its stroke's cap, join and miter limit in the text document, #6472; `.ai` and EPS import, PDF import: tracking and manual kerning in horizontally scaled type take the Horizontal Scale, as the file's page spaces such type; `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke, #3179; `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid, #3179; `.ai` and EPS import: stroked type keeps its stroke's cap, join and miter limit from the text document, #6472; `.ai` save: type in a variable font's named instance names its design-space setting, #6388; `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page, #3179; `.ai` and EPS import: a note for type in an envelope, #3179; `.ai` and EPS import: the live envelope and warped type check sees white art; PDF import: DeviceN images of one ink or of process plates keep their inks, #6348; `.ai` import: images in a spot ink's Separation space keep their ink, #6350; PDF import: Separation images keep their ink, #6314; PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; PDF import: image masks open colorized by the color they are painted in, #6313; EPS and `.ai` editing data: a ligature the page names by a private-use glyph matches its text object; EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`, `OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public @@ -62,6 +64,7 @@ private editing data. VectorCraft: open with different, unconverted RGB colours than before. A PDF-compatible `.ai` opens on its PDF part's trim boxes (its artboards), the bleed boxes round them becoming the document's bleed, so art in the bleed shows outside the artboard, within the bleed guide (#1266). + With editing data explicitly requested, a single artboard larger than its 14,400 pt capped PDF page opens at its full size, with a warning (#6865). A PDF saved with its editing data (`AIPDFPrivateData`) opens the same way. The swatches of a version 7 `.ai` palette, which carry no names, come in named by their spot color, gradient or values. @@ -341,6 +344,7 @@ Stop opacity on translated artboards remains outside this regression coverage. | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-11 | minor | Newly imported SVG/SVGZ titles use the imported-text rules: generated Untitled names and control-only titles give the filename fallback, other control characters are removed, and titles are limited to 65,536 characters. Native and Preserve Editing titles remain unchanged (#3626). | | 2026-11-11 | minor | Native and Preserve Editing restores keep an existing File Info title exactly, including repeated spaces; imported-text fallback titles alone are normalized (#3626). | | 2026-11-03 | minor | An imported document's own title goes into File Info only (#3626): an SVG's root ``, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title` is File Info's title (`metadata.title` in the native format, left out when unset so other documents save unchanged and older versions ignore it; written by exports); the document's name, which the tab, Save As and the default export names use, stays the file's name. | diff --git a/docs/gaps.md b/docs/gaps.md index 83afa40942..83d6641445 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (#6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -212,6 +212,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi stroke. Other Art brushes (None or another colorization, another width, Proportional, between guides, flipped, art with strokes, gradients or patterns) and type with an Art brush keep the drawn look. +- **Done (2026-11-12):** Explicitly requested editing data retains a single artboard larger than a capped 14,400 pt PDF page (#6865); the default import and other size mismatches remain unchanged. Synthetic importer regression; scores unchanged. - **Missing:** faithful layer and group structure from the editing data in real files (#951: 109 layers instead of 14), open speed on large files (#758), live effects, Scatter, Pattern and Bristle brushes on paths, the Art brushes listed above as keeping the drawn look, a path's own Calligraphic or Art brush settings, and symbols as live objects (#637; plain art styles read Drop Shadow, Outer Glow and Feather as live effects; pattern fills and global colours are read since #1025, placed (linked) files since #1264). A PDF-compatible `.ai` carries an editing copy of the art in the form @@ -858,6 +859,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | An imported document's own title (an SVG's root `<title>`, a PDF's XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name stays the file's name (#3626); no scored gap or estimate reassessed. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | G5: built-in Energy Beams brush library (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Missing trimmed; estimate 5–15 → 4–14 h; MCP Brush libraries list Energy Beams. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 51b0eb31bb..c130b877ee 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,8 @@ # Roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: M10/G5: Energy Beams brush library; MCP docs list Energy Beams; previously minor: M10/G5: Skill Icons symbol library; MCP docs list Skill Icons; previously minor: M10/G5: Status Effects graphic style library; MCP docs catch-up for symbol libraries; previously minor: M10/G5: Inventory Icons built-in symbol library; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: M10/G5: Game HUD graphic style library; previously minor: resizable icon-strip flyouts and bottom-aligned panel footers, #5713; previously minor: M10/G5: Map Markers built-in symbol library; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview are in; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Vector Halftone preview update (2026-11-10): temporary dot selection marks are hidden, and the bounding box stays available (#6369). > @@ -10,6 +12,8 @@ ## Feature submission evidence +M4: Explicitly requested editing data keeps a single artboard larger than its 14,400 pt capped PDF page (#6865). Scores and estimates unchanged. + Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. Scores and estimates unchanged. Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged. @@ -143,7 +147,7 @@ native wording review, layout checks and a web Devanagari font remain. [Notes](l | M1 | Selection, transform, layers, MCP (common command tools, strict keys, session resources and version-aware result hints) | ✅ mostly done (document-tab artwork drag with lossless resource transfer and one target Undo; malformed MCP app replies discard the connection without replay, #1761; bounded control socket waits and connection-slot recovery, #1726; bounded synthetic control input, #1709; no-op anchor Align keeps Undo, Redo and saved state (#1180 follow-up); transform reference point snaps to anchors/centres; rotated objects keep a rotated bounding box and their angle; Free Transform, Scale and Reflect along a rotated box pending; the Transform fields take one Tab each, the W/H link after them) | 4–6 | | M2 | Drawing tools + smart guides | ✅ mostly done (#3380 deletion creates independent selected path pieces and keeps bounds-fitted gradients in place; endpoint highlighting remains open; Polygon/Star exact-size count persistence, with repeated-drag, tool-switch and Cancel regression coverage; Curvature point markers without direction handles (#1236, in part); Direct Selection segment click/Delete preserves neighboring edges, #1278; Flare, Reshape, Live Corners widget dragging and per-corner radii and kinds landed, on any path's corners; Shaper rough-shape recognition and orientation steps, the remaining Pen modifier nuances; Shaper Groups with editable sources and separate fill/stroke edits landed; custom ruler origin with drag preview, active-artboard reset, relative X/Y fields and native round-trip (#1212); construction widget/refinements pending); a shape just drawn moves by its centre point and turns from the zone outside a corner with its tool still active (#5477); Tangent and straight-segment perpendicular snapping use saved Smart Guides Geometric Guides preferences and Control bar quick access (#2537), including the existing Snap to Glyph and Alignment Guides toggles (#6164); no View submenu; Copy and Cut with the Pen or Curvature tool take the anchors a Cmd/Ctrl-lent Direct Selection picked (#6123) | 12–18 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Eyedropper copies transformed character dimensions (#6404) and styles highlighted text only (atomic transparency rejection); Eyedropper reads visible locked and outside-isolation sources (#1251); Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork (screen-bounded Edit chip list, #3416); gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; visible strokes when paint is added to inherited None/zero-width art (#1253); stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool (Shift-double-click enables adjoining adjustment in Width Point Edit; finite-arithmetic follow-up regressions and full CI passed ([Validation evidence](target-app-parity.md#evidence-what-ships-today-by-area))), Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries; CSS library export preserves fractional linear and radial gradient stop positions. Left: freeform gradients in SVG/PDF, mixed spot/process gradients in SVG and PDF gradients mixing a spot color with RGB or Lab stops export as stops or process colors; PDF export writes dithered gradient fills with spot stops, and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, as images in process colors; confirm the Scale Strokes & Effects default; Recolor model selection is available through the engine and MCP only (#2520); the dialog retains its existing controls | 4–8 | -| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | +| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | Explicit editing-data import keeps oversized capped-page artboards (#6865; estimates unchanged); PDF authored ActualText preserved once in editable import (#6165; first-glyph line/style, bounded input; estimates unchanged); PDF export keeps harakat and zero-advance Noto Arabic dot glyphs in one compatible batch per layout line, with per-glyph vertical offsets (#6309). `crates/pdf/src/tests/realtext.rs` (`mark_text_*`) checks decoded cluster mappings, mixed Arabic/Latin/digits, contour placement against outline export, vertical columns, differing path rotations and PDF/X-1a/3/4. Editable-text import preserves authored ActualText separately (#6165), with cross-line/style layout limits; separately styled or differently rotated clusters and external viewer search/copy remain outside this export-only evidence. `.ai`/EPS type from the text document keeps its paragraphs' tab stops (#6147); `.ai`/EPS type from the text document keeps auto leading per line, its hyphenation options, and may show more of the last lines the file sets in an area frame of its own, when growing the frame by its lines' deepest descent plus 0.05 pt shows them (#6137); ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), shared guarded PDF writer and windowed reader ([pdf-backend.md](pdf-backend.md)), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out (EPS legacy mesh corners, vector opacity masks and raster page comparison verified; closed SPLINE flags round-trip and circular paths export as native CIRCLE/ARC, #1299), EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, TIFF in Export for Screens and Asset Export, clipboard flavours, File Info; AF.1–AF.4 add Affinity import (native layers, artboards, vectors, text and images from Affinity 1–3 files; the embedded preview only as a warned fallback; AF.4 expands current `.af` fixtures and every-board round-trip validation); EPS Save and Save As keep an opened EPS as EPS with its page frame and complete editable VectorCraft document (2026-10-26; G9 import fidelity unchanged). Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, Affinity export, polish; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts | 3–6 (existing scope; Affinity export unestimated) | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); opaque solid-fill knockout paths draw once in hard-edge/supersampled raster export, with analytical-AA compositing unchanged; file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); Craquelure/Mosaic Tiles/Stained Glass distance searches optimized with pixel-invariance checks and the `texture_drag_perf` probe (#1257); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (live compound shapes: Make, Alt-click a Shape Mode, member modes, Release, Expand (#4522); single-result Pathfinder names and SVG IDs retained (#1293); Divide open-cutting-path geometry, appearance and history verified (#1307; existing segment cap remains); Simplify preview/counts and rewritten-path component selection verified (#1272; the slider opens at the Auto-Simplify level; a Direct Selection part of a path simplifies on its own); Compound appearance and member fill-rule edits verified (#1197); Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder gap detection, large-offset bug open); Shape Builder Gap Detection shares Live Paint’s spatial gap finder and temporary merge bridges (#2526); Custom is capped at 72 pt in the existing options dialog | 3–6 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | Spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof and the custom dictionary and Ignore All commands and Auto Spell Check with its right-click suggestions, Ignore All and Add to Dictionary, and Spelling › Auto Spell Check in the context menu of type, and the Edit Custom Dictionary dialog (#4936; the Check Spelling dialog remains); Right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); A line a soft return ends is justified as a wrapped line is (#6137); Indian-language pattern hyphenation preserves syllables in both composers (#4947); 🟡 Hanging punctuation keeps its published amounts under Glyph Scaling, and Standard burasagari counts a hung line start as room; Fullwidth `!` / `?` keep an upright fallback in vertical type; Hyphenation… and Justification… per paragraph (#2310, #2755), Auto Leading also driving the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; Document-point character controls after object/group scaling (#1034; optional font-size preservation remains separate), Character/Paragraph Styles (explicit Auto-leading resets and local leading overrides), per-paragraph paragraph attributes, mixed paragraph alignment indication (headless regressions, full integration CI and visual review pass), Area Type Options (with Width/Height, Auto Size and Shrink Text to Fit; overflow reported to agents and vertical Align), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel (including font-defined Stylistic Sets), Find Font (Document, Recent and System replacement sources, #5217), installed system fonts in every font menu (fonts registered with Windows outside its font folders, the XDG and Flatpak host font folders on Linux, and fonts installed while the app runs, listed when it comes back to the front; searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, show recently applied families first (#3515), filter by script and kind, list a family's styles, and star favourites; the Glyphs panel and Find Font's Replace With use the same menu without the live preview) and found by name on open/import (full-name aliases and Missing Fonts searches verified, #1258; Windows font-service QA pending), variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects and Options with draggable start/centre/end brackets (slide, flip), tab stops + Tabs panel (Above Text floats it over the selected text, its ruler at the canvas zoom so stops line up with the columns, #729), inline symbols in text (beyond parity), Hebrew/Arabic bidirectional text, Paragraph Direction, Every-line/Single-line Composer per object (ragged text too) done; paint-only Arabic harakat keep joining with GDEF-mark ownership and Insert Kashidas tatweels on the base letter's paint (`tests::harakat`); tab leaders, spell check, vertical type pending | 45–60 | @@ -190,6 +194,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | | 2026-11-11 | minor | Eyedropper copies effective character dimensions between transformed text objects (#6404); scores and estimates unchanged. | | 2026-11-11 | minor | M10/G5: Energy Beams built-in brush library; MCP docs catch-up; estimates unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d42a4d988b..f698e90090 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,8 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Live Paint Bucket update (2026-11-12): one drag fills the faces crossed in the same group (#6373). > @@ -503,7 +505,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Layers, artboards, document setup | 5 | 82% | Layers panel: a confirmation before deleting layers that hold art, Collect for Export, row context menus, the Layer Options dialog for several rows with mixed values, Paste Remembers Layers for sublayers, drag-scrolling a long panel while dragging rows; artboard preset polish (artboard escape routing and document setup modal handoff done; Move Artwork for artboard X/Y edits, Rearrange All Artboards, Document Setup, New Document and the rest of the Layers panel are done) | 4–7 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 85% | global/video rulers, Spacing Guides' distance readouts and spacing for resized or drawn art, Smart Guides' angle and distance readouts while drawing, Alt swapping a ruler guide's orientation, ruler guides selected together with art (marquee, Shift-click), ruler guides as Layers rows with their own lock and hide (#451: Lock Guides locks them all; Make Guides paths are Layers rows already) (dragging existing guides is done, #414; new guides snapping to side midpoints and artboards, and artboard guides, #451; the drawing tools snapping, with Construction Guides, #506; Spacing Guides for moves, #394; moves snapping to the grid, #740; ruler guides pulling drawn points with Smart Guides on, and "center" and "anchor" labels while hovering with the Selection tool, #812) | 2–4; status-bar X/Y uses the active ruler origin and General units | -| File formats | 6 | 85% | Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | +| File formats | 6 | 85% | Explicit editing-data oversized single-artboard import fixed (#6865); scores and estimates unchanged. Affinity export, and the rest of Affinity import (adjustments, blur and bevel effects, brushes, live symbol instances, text threading), DWG (no open spec: DXF instead), PSD placement as layers (Photoshop Import Options; the merged image is done), EPS checked against more real files users send (Inkscape files open as vectors; other generators' constructs are tested with programs written from the PostScript Language Reference only; CID-keyed CJK type and `CCITTFaxDecode` images not read), legacy `.ai` files saved without the prolog that defines their operators (their own operator set isn't read yet), the rest of Illustrator's editing data (appearances and live effects, symbols, pattern fills, brushes, placed files: #637), the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X, PDF layers, presets and Affinity import are done | 6–10 (existing scope; Affinity export unestimated) | | Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 | | Print, colour management, separations, flattener | 3 | 80% | print preview fidelity (flattener presets in PDF and print, overprint in PDF and composite print, Print dialog, presets, PostScript, marks, separations, Print Tiling, print overprint/flattener/bitmap options and printer profile are done, as are the Japanese papers in its Media list: JIS B4/B5/B6, はがき, 往復はがき, 封筒 長形3号/長形4号/角形2号/角形3号 and 四六判, beside the ISO B4/B5 that settings saved before them keep) | 1–2 | | Automation | 3 | 65% | scripting surface, batch (MCP drops malformed app replies without replay; Variables: text/visibility variables bound to objects, an object may carry several, dataset rows applied in one undo step, panel and Window › Variables with dialogs; image/graph kinds and dataset import/export open) | 6–10 | @@ -1508,6 +1510,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `.ai` and EPS editing-data import keeps each path's Attributes panel note (`%AI3_Note:`, `%%Note:`, more lines in `%AI5_NoteMore:`, up to 1,024 characters) and Show Center (`1 Ap`); a note or Show Center applies to every path after it until the next one, and a note before a group or compound path is not theirs (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). `.ai` and EPS editing-data import reads object tags (`/identifier (string) XT`) on the object before them, whatever kind of object it is: `AdobeURL` becomes the object's URL, and every other tag is kept in the object's data in order, up to 64 per object; a tag longer than 64 KiB, and tags in a pattern's or brush's art, are ignored. A tag on art that shows no longer stops the editing data from being read, and brush art that holds tags is read (`crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_illustrator.rs`). A `.ai` saved without PDF compatibility (its PDF part only a placeholder page) is placed and pasted from its editing data, as it opens: File › Place places the art of the layers that show on the first artboard (`file.place`'s `page` places another one), and Paste of it as PDF data (`clipboard.importPdf`) takes every object. `document.open` with `pages` naming every page of such a file opens it the same way (`crates/engine/src/cmd/fileio/tests_aiimport.rs`). + Explicitly requested editing data keeps the full single artboard when its PDF page is capped at 14,400 pt (#6865; `crates/eps/src/import/tests_native.rs`). Scores and estimates unchanged. A `.ai`, or a PDF saved with Illustrator's editing data, whose PDF part can't be read (a stream the import refuses) opens from its editing data, with the PDF part's error as its first warning; File › Place places its first artboard the same way, and Paste of it as PDF data with `editingData: true` takes every object (part of #4366; `crates/engine/src/cmd/fileio/tests_aiimport.rs`). - EPS and DXF in and out, EMF/WMF in and out; PNG (with PNG-8), JPEG, WebP, GIF, TIFF, BMP, Targa and layered PSD export; grayscale TIFFs with an alpha channel or other extra channels place, relink and open, the alpha as the image's transparency (#1466 in part; CCITT Group 3 and planar files with extra channels are not read); PSD and PSB files open and place, linked or embedded, as their merged image in every colour mode and depth (#918). EPS import runs a PostScript Level 3 interpreter written from the PostScript Language Reference: resource categories and `resourceforall`, local and global VM (`currentglobal`/`setglobal`/`gcheck`), `clipsave`/`cliprestore`, subarrays and substrings that share storage, the standard dictionaries in `systemdict`, `dictstack`, `internaldict`, executable filters and strings, `load` by any key, `//name` immediate names, `token`, `execform`, user paths, `cshow`, `rootfont`, `cachestatus`, colour rendering and halftone queries, file `status` and data read with `flushfile`, and `pdfmark` marks dropped (Discord feedback, #466, #469, #474). Masked images (`ImageType 3` in its three interleavings) and reusable streams are read, and an image's several data sources are read in turn, as the reference defines (procedures that read one file get their own rows: such images came in banded, #498). `restore` puts dictionaries back as they were at the `save`, so programs that count their saves in a dictionary read to their end (part of #505). Tiling patterns come in as pattern swatches, uncoloured ones too; Type 3 fonts' glyphs as their outlines, spaced by `ashow`/`widthshow`/`xshow`/`kshow`; mesh and function-based shadings (types 1, 4–7) as gradient meshes; sampled functions (type 0) and Flate/LZW predictors are read. It is tested with programs written from the specification in the ways common generators use these features; Inkscape/cairo EPS files open as vectors. When a file's PostScript still can't be read, it opens as its TIFF preview, its Windows metafile preview (as vectors) or its EPSI preview, and the warning (in the status bar, `document.open`/`app.open`/MCP `open_file` replies and `vectorcraft-cli info`) names the error, the operator that raised it and the procedures it ran in. Legacy PostScript `.ai` files (Illustrator 3–8, written with the prolog that defines their operators) keep their groups and nested groups (`u` … `U`), between and inside clipping groups; EPS and EMF/WMF import nest clips (and groups) at most 128 deep, so a hostile file can't exhaust the stack. DXF TEXT justified Fit or Aligned spans its two points (stretched, or scaled as a whole). PDF export writes type as real, searchable text in embedded subset fonts by default, as Illustrator's PDF presets do (Convert Text to Outlines is an option; printer's marks stay shapes), so text-heavy PDFs stay small (#733). DXF export writes each fill as its outlines followed by its hatch, so laser and cutter software, which skips hatches, gets filled art and type too (#546). - JPEG XL (`.jxl`) images open and place, linked or embedded, as their first frame, decoded with jxl-oxide and stored as PNG: 16-bit and floating-point samples become 8 bits, alpha is kept, and an image past the pixel allowance is refused from its header (part of #2306; `crates/engine/src/cmd/fileio/tests_jxlread.rs`). @@ -2073,6 +2076,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-03 | minor | File Info on open: an imported document's own title (an SVG's root `<title>`, a PDF's, `.ai`'s or `.ait`'s XMP `dc:title` else its Info `Title`) goes into File Info only; the document's name (tab, Save As, default export names) stays the file's name (#3626); scores and estimates unchanged. | | 2026-11-12 | minor | Type: kerning at the Type tool's caret at the start of a line (the text's, a paragraph's, or after a forced line break) sets the space before the character after it (`kerningBefore`), which moves it and the rest of its line; it did nothing at the text's start and kerned the invisible line break at a paragraph's (#5111). Scores and estimates unchanged. | | 2026-11-12 | minor | Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | From a1ece0052b7a7ae1aca58dd395bd2499d6c9cd25 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 11:26:25 -0800 Subject: [PATCH 09/35] MCP: hidden library ids name their library in requests and are rewritten only as whole ids Follows #6608. ui.inspect shows a library loaded from outside the read roots as loaded/<file name>. A request may now name the library or its panel by that id in a panel, library or panels param, in a docking destination (anchor, before, onto, above, below) and in window.panel.layout's action: ui.set {panel} reopens the closed panel instead of floating a new one. The reply to such a request, an error included, shows the shown id. ui.inspect rewrites only whole ids, and only in the fields that hold library and panel ids, so a readable library's id that begins with a hidden one keeps its path, and status text, the command palette query and a script's report keep their text. A hidden library does not take the id of a library loaded without a path. ui.inspect also leaves out of recorded action steps the path and folder params, params named like destinationFolder and the entries of a paths list that lie outside the read roots, also inside a batch step's commands. It hides a library id holding a path written with either separator, and checks a folder preference against the roots as written, without trimming spaces. --- crates/ui-egui/src/control.rs | 221 ++++++++++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 183 ++++++++++++++++++++ docs/mcp.md | 22 ++- 3 files changed, 399 insertions(+), 27 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ef6a6d20c8..d0be1a13ef 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -309,10 +309,22 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { "fileDialog": app.picks.waiting_kind(), }); let ids = unreadable_library_ids(app); - hide_library_ids(&mut inspected, &ids); + for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { + if let Some(v) = inspected["ui"].get_mut(*field) { + hide_library_ids(v, &ids); + } + } + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + if let Some(sets) = inspected["ui"].get_mut("action_sets") { + swap_library_params(sets, &pairs); + } inspected } +/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel +/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. +const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; + /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -322,9 +334,10 @@ const PANEL_FIELDS: [&str; 7] = /// The ids of the loaded libraries (`loaded/<path>`) that the UI state refers to and whose file the /// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel -/// and the Color Guide's limit, and by a panel id (`library:<kind>:loaded/<path>`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts. Text the person types (a command palette query, a -/// workspace's name) is not read. The ids are numbered with the session's loaded libraries as the +/// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of +/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an +/// action's name) is not read. The ids are numbered with the session's loaded libraries as the /// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so /// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library /// under the same id. @@ -334,6 +347,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut ids); } + collect_library_params(&ui["action_sets"], &mut ids); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { collect_library_panel_ids(layout, &mut ids); @@ -382,30 +396,81 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's /// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - if ids.is_empty() { + let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); + swap_library_ids(v, &pairs); +} + +/// Text that a person or a script wrote, by its key: a command palette query, a script's report and a +/// workspace's name. [`swap_library_ids`] leaves it as written. +const TEXT_FIELDS: [&str; 3] = ["palette_query", "scriptReport", "name"]; + +/// Writes each `from` id of `pairs` as its `to` id wherever `v` holds it as a whole id ([`swap_whole_ids`]), +/// in strings and keys, except in the [`TEXT_FIELDS`]. +fn swap_library_ids(v: &mut Value, pairs: &[(&str, &str)]) { + if pairs.is_empty() { return; } - let hide = |s: &mut String| { - for (id, shown) in ids { - if s.contains(id.as_str()) { - *s = s.replace(id.as_str(), shown); + match v { + Value::String(s) => { + if let Some(swapped) = swap_whole_ids(s, pairs) { + *s = swapped; } } - }; - match v { - Value::String(s) => hide(s), - Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)), Value::Object(fields) => { - for (mut key, mut item) in std::mem::take(fields) { - hide(&mut key); - hide_library_ids(&mut item, ids); - fields.insert(key, item); + for (key, mut item) in std::mem::take(fields) { + if TEXT_FIELDS.contains(&key.as_str()) { + fields.insert(key, item); + continue; + } + swap_library_ids(&mut item, pairs); + fields.insert(swap_whole_ids(&key, pairs).unwrap_or(key), item); } } _ => {} } } +/// `s` with each `from` id of `pairs` written as its `to` id where it is a whole id: the whole string, +/// or a part that starts the string or follows a `:`, `"` or `` ` `` (a panel id, a quoted id) and ends +/// the string or a quote. An id that only begins or ends a longer one stays. `None` when nothing changed. +fn swap_whole_ids(s: &str, pairs: &[(&str, &str)]) -> Option<String> { + let mut out: Option<String> = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if !text.contains(from) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from) { + let end = at + from.len(); + let starts = at == 0 || text[..at].ends_with([':', '"', '`']); + let ends = end == text.len() || text[end..].starts_with(['"', '`']); + if at >= copied && starts && ends { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does `v` hold a string or a key that names a loaded library (`loaded/…`)? +fn names_a_loaded_library(v: &Value) -> bool { + match v { + Value::String(s) => s.contains("loaded/"), + Value::Array(items) => items.iter().any(names_a_loaded_library), + Value::Object(fields) => fields.iter().any(|(key, item)| key.contains("loaded/") || names_a_loaded_library(item)), + _ => false, + } +} + /// The preferences that hold a folder, by their keys in `ui.engine_prefs` and the Preferences dialog. const PREF_FOLDERS: [&str; 6] = ["fontsFolder", "pluginsFolder", "scratchPrimary", "scratchSecondary", "recoveryFolder", "templatesFolder"]; @@ -419,9 +484,43 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } +/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in +/// `Folder` (`destinationFolder`, `searchFolder`)? +fn is_path_param(key: &str) -> bool { + matches!(key, "path" | "folder") || key.ends_with("Folder") +} + +/// The params of each step of each action of action set `set` (`ui.action_sets`). +fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<String, Value>> { + let actions = set.get_mut("actions").and_then(Value::as_array_mut).into_iter().flatten(); + let steps = actions.filter_map(|action| action.get_mut("steps").and_then(Value::as_array_mut)).flatten(); + steps.filter_map(|step| step.get_mut(1).and_then(Value::as_object_mut)) +} + +/// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the +/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { + params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); + if let Some(Value::Array(paths)) = params.get_mut("paths") { + paths.retain(|p| p.as_str().is_none_or(readable)); + } + for v in params.values_mut() { + leave_out_unreadable_values(v); + } +} + +/// [`leave_out_unreadable_params`] on each object `v` holds. +fn leave_out_unreadable_values(v: &mut Value) { + match v { + Value::Object(fields) => leave_out_unreadable_params(fields), + Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + _ => {} + } +} + /// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files, -/// a library panel's file and folder preferences (saved, and in an open Preferences dialog) outside -/// the read root. The UI state itself is not changed. +/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths +/// in recorded actions' steps outside the read root. The UI state itself is not changed. fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { if let Some(Value::Array(files)) = ui.get_mut("recent_files") { files.retain(|f| f.as_str().is_some_and(readable)); @@ -433,6 +532,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { } } } + for step in ui.get_mut("action_sets").and_then(Value::as_array_mut).into_iter().flatten().flat_map(steps_mut) { + leave_out_unreadable_params(step); + } if let Some(Value::Object(prefs)) = ui.get_mut("engine_prefs") { leave_out_unreadable_folders(prefs); } @@ -448,8 +550,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { /// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) { for key in PREF_FOLDERS { - let Some(path) = prefs.get(key).and_then(Value::as_str).map(str::trim) else { continue }; - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(path) { + let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; + let path = written.trim(); + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { continue; } prefs.remove(key); @@ -494,7 +597,83 @@ fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> { } } +/// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, +/// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking +/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, +/// below}`) and a list of panels. +const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; + +/// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is +/// translated. +const DOCKING_ACTION: &str = "action"; + +/// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` +/// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id +/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { + let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + if shown.is_empty() || !swap_library_params(&mut params, &shown) { + return dispatch(app, ctx, req); + } + let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + match dispatch(app, ctx, &named) { + Outcome::Done(mut reply) => { + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) + } + outcome => outcome, + } +} + +/// Writes each `from` id of `pairs` in the [`LIBRARY_PARAMS`] params of `params`, at any depth, as its +/// `to` id → whether one was. +fn swap_library_params(params: &mut Value, pairs: &[(&str, &str)]) -> bool { + match params { + Value::Array(items) => items.iter_mut().fold(false, |swapped, item| swap_library_params(item, pairs) | swapped), + Value::Object(fields) => fields.iter_mut().fold(false, |swapped, (key, item)| { + let this = match item { + Value::String(s) if LIBRARY_PARAMS.contains(&key.as_str()) => swap_whole_ids(s, pairs).map(|to| *s = to).is_some(), + Value::Array(items) if LIBRARY_PARAMS.contains(&key.as_str()) => items.iter_mut().fold(false, |swapped, item| { + let this = item.as_str().and_then(|s| swap_whole_ids(s, pairs)).map(|to| *item = Value::String(to)).is_some(); + this | swapped + }), + Value::Object(_) if key == DOCKING_ACTION => { + let before = item.clone(); + swap_library_ids(item, pairs); + *item != before + } + _ => swap_library_params(item, pairs), + }; + this | swapped + }), + _ => false, + } +} + +/// Adds to `ids` the loaded library ids that the [`LIBRARY_PARAMS`] params of `v` name, at any depth: +/// a library's id or its panel's id. +fn collect_library_params(v: &Value, ids: &mut std::collections::BTreeSet<String>) { + match v { + Value::Array(items) => items.iter().for_each(|item| collect_library_params(item, ids)), + Value::Object(fields) => { + for (key, item) in fields { + if !LIBRARY_PARAMS.contains(&key.as_str()) { + collect_library_params(item, ids); + continue; + } + for s in item.as_str().into_iter().chain(item.as_array().into_iter().flatten().filter_map(Value::as_str)) { + add_library_id(s, ids); + collect_library_panel_ids(&json!(s), ids); + } + } + } + _ => {} + } +} + +fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; let s = |k: &str| p.get(k).and_then(Value::as_str); match req.method.as_str() { diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 3a84e0b94d..8f6e0f5ab5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -473,6 +473,124 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before +/// it is a relative path, outside the root, and is left out. +#[test] +fn ui_inspect_checks_a_folder_preference_as_written() { + let (mut app, tx, base) = confined("inspect-folder-space"); + let inside = s(base.join("in")); + app.ui.engine_prefs = json!({"fontsFolder": inside, "templatesFolder": format!(" {inside}")}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("fontsFolder").and_then(Value::as_str), Some(inside.as_str()), "{r}"); + assert!(!prefs.contains_key("templatesFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: +/// status text that is that id stays as written. +#[test] +fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { + let (mut app, tx, base) = confined("inspect-status-id"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = theirs.clone(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action +/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +#[test] +fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-action-steps"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": theirs, "steps": [ + ["swatch.library.add", {"library": theirs}], + ["window.panel", {"panel": format!("library:swatches:{theirs}")}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; + assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!( + action["steps"], + json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), + "{r}" + ); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps the file paths (`path`, `folder`, a name ending +/// in `Folder`, `paths`) outside the read root, also in a batch step's commands, and keeps the ones +/// inside it. +#[test] +fn ui_inspect_leaves_out_recorded_paths_outside_the_read_root() { + let (mut app, tx, base) = confined("inspect-action-paths"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["file.open", {"path": a}], + ["file.open", {"path": b}], + ["file.place", {"paths": [a, b]}], + ["batch.run", {"folder": s(base.join("outside"))}], + ["action.batch", {"destinationFolder": s(base.join("outside"))}], + ["command.batch", {"commands": [{"command": "file.open", "params": {"path": b}}]}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["file.open", {"path": a}], + ["file.open", {}], + ["file.place", {"paths": [a]}], + ["batch.run", {}], + ["action.batch", {}], + ["command.batch", {"commands": [{"command": "file.open", "params": {}}]}], + ]); + assert_eq!(steps, &want, "{r}"); + assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 6, "the UI state is not changed"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` hides a library loaded from outside the read root whose id holds a path written with +/// either separator, also one saved on another platform. +#[test] +fn ui_inspect_hides_a_library_path_with_either_separator() { + let (mut app, tx, base) = confined("inspect-separators"); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + app.ui.color_guide_limit = "loaded//home/someone/c.svg".to_string(); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/c.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library does not take the id of a library loaded without a path under the same file +/// name: it shows as `loaded/<file name> (2)`, and a request that names the other library reaches it. +#[test] +fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { + let (mut app, tx, base) = confined("inspect-loaded-id"); + let svg = std::fs::read_to_string(base.join("in/a.svg")).unwrap(); + let loaded = app.session.execute("swatch.library.load", &json!({"data": svg, "name": "b.svg"})).unwrap(); + assert_eq!(loaded["library"], "loaded/b.svg"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -611,6 +729,71 @@ fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable +/// library's id that begins with it stays, and a script's report line and a command palette query +/// that hold it stay as written. +#[test] +fn ui_inspect_rewrites_only_whole_hidden_library_ids() { + let (mut app, tx, base) = confined("inspect-whole-id"); + let hidden = format!("loaded/{}", s(base.join("i"))); + let readable = format!("loaded/{}", s(base.join("in/a.svg"))); + assert!(readable.starts_with(&hidden)); + let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; + app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; + let query = format!("library:swatches:{hidden}"); + app.ui.palette_query = query.clone(); + let text = format!("found {hidden}"); + let source = format!("vc.log({})", json!(text)); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A client can name a hidden library by the id `ui.inspect` shows: `ui.set {panel}` with that panel id +/// reopens the closed panel of the library, and the reply does not show its path. +#[test] +fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { + let (mut app, tx, base) = confined("set-hidden-panel"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let real = format!("library:swatches:{theirs}"); + app.ui.docking_hidden.insert(real.clone(), hidden_place()); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "library:swatches:loaded/b.svg"; + assert!(r["result"]["ui"]["docking_hidden"].as_object().unwrap().contains_key(shown), "{r}"); + let r = request(&mut app, &tx, "ui.set", json!({"panel": shown})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!app.ui.docking_hidden.contains_key(&real), "the closed panel is reopened: {r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // A docking command takes the shown id as its destination too. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "layers", "anchor": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // An error that quotes the panel shows the id the client gave, and other text is not rewritten. + let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let r = request(&mut app, &tx, "ui.set", json!({"status": "loaded/b.svg"})); + assert_eq!((r["ok"].as_bool(), app.ui.status.as_str()), (Some(true), "loaded/b.svg"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index 996908799f..ac035e7cfc 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -143,15 +143,25 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, + a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the - Color Guide's `ui.color_guide_limit` and a closed panel's key in `ui.docking_hidden`, also when its panel is - closed. When another hidden library has the same file name, the libraries take `loaded/<file name>`, - `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session - first, then those only the UI state names), and each one keeps its id in both replies and in the library - commands below. The other entries keep their order, and the app's own state is not changed. + Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` + params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + either separator counts as a path, also one saved on another platform. When another hidden library has the same + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of + their full ids (the libraries loaded in this session first, then those only the UI state names), and each one + keeps its id in both replies and in the library commands below. A hidden library skips an id that a library + loaded without a path already has. The other entries keep their order, and the app's own state is not + changed. A request may name such a library or its panel by the id + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, + `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: + a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From a2c08dec2724b06fd421dd0cffd1c9fb318c6e63 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 13:39:28 -0800 Subject: [PATCH 10/35] MCP: replies hide a library path outside the read roots wherever it appears Under the read roots, every control reply now shows a hidden library's id as loaded/<file name> and its path as the file name in every string and key: status text, dialogs, the command palette query, a script's report, recorded steps and errors. The id and path are replaced at any boundary, so a path between spaces, brackets or quotes, followed by a period or by more path text (<path>.bak), or quoted with escaped backslashes is hidden too. An occurrence that continues into a longer path stays only when the request may read that longer path, so a readable library's id that begins with a hidden one keeps its text. The libraries hidden after the request ran are hidden, and for a request that names one, those hidden before it too. A reply with no path separator in it is not searched. Encoded file data (dataBase64, pngBase64) is sent unchanged. Recorded steps in ui.inspect leave out a path outside the read roots under path, paths, file, files, folder and any param whose name ends in Path, Paths, File, Files or Folder (diffPath, fontFiles, destinationFolder), and an absolute path or file: URL outside the roots under any other param or in any list. The file: scheme is matched in any case, a URL's %XX escapes are decoded before the check, a file: URL inside the roots stays, and a file: URL that names another host is always left out. file_access::is_path_key holds the list of path params, and testkit's is_path_key uses it. --- crates/engine/src/file_access.rs | 7 + crates/testkit/src/strategies.rs | 12 +- crates/ui-egui/src/control.rs | 273 +++++++++++++++++++++---- crates/ui-egui/src/control/tests.rs | 57 ++++++ crates/ui-egui/src/tests/automation.rs | 113 ++++++++-- docs/mcp.md | 16 +- 6 files changed, 410 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 73c8ba8efe..9d05b17272 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, +/// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, +/// `destinationFolder`)? +pub fn is_path_key(key: &str) -> bool { + matches!(key, "path" | "paths" | "file" | "files" | "folder") || ["Path", "Paths", "File", "Files", "Folder"].iter().any(|end| key.ends_with(end)) +} + /// May `path` be written here? Always, without roots in force. pub fn check_write(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Write))?; diff --git a/crates/testkit/src/strategies.rs b/crates/testkit/src/strategies.rs index 10b0608001..372152870d 100644 --- a/crates/testkit/src/strategies.rs +++ b/crates/testkit/src/strategies.rs @@ -502,15 +502,17 @@ pub fn param_keys(doc: &str) -> Vec<String> { out } -/// Parameter-name keys that name a file or folder a command may write: fuzzing these with -/// arbitrary strings could write files anywhere, so callers substitute a temp path. +/// Parameter-name keys that name a file or folder a command may write +/// ([`vectorcraft_engine::file_access::is_path_key`]): fuzzing these with arbitrary strings could +/// write files anywhere, so callers substitute a temp path. pub fn is_path_key(k: &str) -> bool { - matches!(k, "path" | "diffPath") || is_folder_key(k) + vectorcraft_engine::file_access::is_path_key(k) } -/// Path keys that name a folder a command creates or writes files into. +/// Path keys that name a folder a command creates or writes files into: `folder` or a name ending in +/// `Folder` (`destinationFolder`). pub fn is_folder_key(k: &str) -> bool { - matches!(k, "folder" | "destinationFolder") + k == "folder" || k.ends_with("Folder") } /// The location to send under the path key `k`: `safe_path` for a file, a folder next to it for diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d0be1a13ef..c38baf5165 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -308,23 +308,10 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), }); - let ids = unreadable_library_ids(app); - for field in PANEL_FIELDS.iter().chain(&LIBRARY_ID_FIELDS) { - if let Some(v) = inspected["ui"].get_mut(*field) { - hide_library_ids(v, &ids); - } - } - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - if let Some(sets) = inspected["ui"].get_mut("action_sets") { - swap_library_params(sets, &pairs); - } + hide_library_ids(&mut inspected, &unreadable_library_ids(app)); inspected } -/// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold library ids or panel -/// ids besides [`PANEL_FIELDS`]: the library panels, the Color Guide's limit and the workspaces. -const LIBRARY_ID_FIELDS: [&str; 4] = ["library_panels", "color_guide_limit", "custom_workspaces", "workspace_changes"]; - /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel /// popped out of the collapsed column, the docking layout, closed panels' places and groups, the panels /// shown as icons or collapsed, and the older floating panels' record. @@ -336,8 +323,8 @@ const PANEL_FIELDS: [&str; 7] = /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of /// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of -/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an -/// action's name) is not read. The ids are numbered with the session's loaded libraries as the +/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a +/// command palette query, a workspace's or an action's name) is not read. The ids are numbered as the /// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so /// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library /// under the same id. @@ -354,11 +341,16 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { } } collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { + // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded + // without a path. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { add_library_id(id, &mut ids); } let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned()); - // Only the ids the UI state refers to are replaced in the reply. + // Only the ids the UI state and the session refer to are replaced in the reply. hidden.retain(|(id, _)| ids.contains(id)); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); @@ -393,11 +385,106 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str } } -/// Writes each id of `ids` as the id the reply shows instead, wherever `v` holds it: in a library's -/// id, in its panel's id (`library:swatches:loaded/<path>`) and in keys. +/// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name +/// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id +/// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's +/// report and a recorded step. An occurrence followed by a character that continues a path +/// ([`continues_path`]) stays only when the request may read the longer path +/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - let pairs: Vec<_> = ids.iter().map(|(id, shown)| (id.as_str(), shown.as_str())).collect(); - swap_library_ids(v, &pairs); + replace_in_value(v, &hidden_text(ids)); +} + +/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the +/// path in it with the file name shown, the path written with either separator too, and with each +/// `\` doubled (a path quoted with `{:?}`). +fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = Vec::new(); + for (id, shown) in ids { + let path = &id["loaded/".len()..]; + let name = shown.strip_prefix("loaded/").unwrap_or(shown); + let back = path.replace('/', "\\"); + for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] { + for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] { + if !pairs.iter().any(|(f, _)| *f == from) { + pairs.push((from, to)); + } + } + } + } + pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len())); + pairs +} + +/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each +/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the +/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` +/// (`dataBase64`, `pngBase64`), stays as it is. +fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { + if pairs.is_empty() { + return; + } + match v { + Value::String(s) => { + if let Some(replaced) = replace_text(s, pairs) { + *s = replaced; + } + } + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Object(fields) => { + for (key, mut item) in std::mem::take(fields) { + if key.ends_with("Base64") { + fields.insert(key, item); + continue; + } + replace_in_value(&mut item, pairs); + fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + } + } + _ => {} + } +} + +/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes +/// them. `None` when nothing changed. +fn replace_text(s: &str, pairs: &[(String, String)]) -> Option<String> { + let mut out: Option<String> = None; + for (from, to) in pairs { + let text = out.as_deref().unwrap_or(s); + if from.is_empty() || !text.contains(from.as_str()) { + continue; + } + let mut next = String::with_capacity(text.len()); + let mut copied = 0; + for (at, _) in text.match_indices(from.as_str()) { + let end = at + from.len(); + if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + next.push_str(&text[copied..at]); + next.push_str(to); + copied = end; + } + } + if copied > 0 { + next.push_str(&text[copied..]); + out = Some(next); + } + } + out +} + +/// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, +/// `-` or `~`? A space, punctuation, a quote, a sentence's final period and the end of the text end it. +fn continues_path(rest: &str) -> bool { + rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) +} + +/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, +/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden +/// one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. +fn readable_continuation(text: &str) -> bool { + let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); + let path = &text[..end]; + readable(path.strip_prefix("loaded/").unwrap_or(path)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -484,10 +571,69 @@ fn readable(path: &str) -> bool { vectorcraft_engine::file_access::check_read(path).is_ok() } -/// Does a recorded step's param `key` hold a file or folder path: `path`, `folder` or a name ending in -/// `Folder` (`destinationFolder`, `searchFolder`)? -fn is_path_param(key: &str) -> bool { - matches!(key, "path" | "folder") || key.ends_with("Folder") +/// Is `s` a path the request may not read, written as an absolute path (either separator, with or +/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is +/// never readable. +fn unreadable_absolute(s: &str) -> bool { + match file_url_path(s) { + Some(path) => !path.is_some_and(|p| readable(&p)), + None => { + let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); + absolute && !readable(s) + } + } +} + +/// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? +fn readable_value(s: &str) -> bool { + match file_url_path(s) { + Some(path) => path.is_some_and(|p| readable(&p)), + None => readable(s), + } +} + +/// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` +/// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). +/// `file:///C:/x` names `C:/x`. +fn file_url_path(s: &str) -> Option<Option<String>> { + let rest = s.get(..5).filter(|scheme| scheme.eq_ignore_ascii_case("file:")).map(|_| &s[5..])?; + let Some(rest) = rest.strip_prefix("//") else { return Some(Some(percent_decoded(rest))) }; + let rest = if rest.get(..9).is_some_and(|host| host.eq_ignore_ascii_case("localhost")) { &rest[9..] } else { rest }; + if !rest.starts_with('/') { + return Some(None); + } + let path = match rest.as_bytes() { + [b'/', drive, b':', ..] if drive.is_ascii_alphabetic() => &rest[1..], + _ => rest, + }; + Some(Some(percent_decoded(path))) +} + +/// `s` with `%XX` escapes decoded, as the SVG importer decodes an href. +fn percent_decoded(s: &str) -> String { + if !s.contains('%') { + return s.to_string(); + } + let mut out = Vec::with_capacity(s.len()); + let mut rest = s.as_bytes(); + while let Some((&b, tail)) = rest.split_first() { + let hex = tail + .get(..2) + .filter(|h| h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) if b == b'%' => { + out.push(v); + rest = tail.get(2..).unwrap_or_default(); + } + _ => { + out.push(b); + rest = tail; + } + } + } + String::from_utf8_lossy(&out).into_owned() } /// The params of each step of each action of action set `set` (`ui.action_sets`). @@ -498,22 +644,35 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<Strin } /// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the -/// paths the request may not read: a path param ([`is_path_param`]) and each path of a `paths` list. +/// paths the request may not read: the value of a path param +/// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` +/// and the like), each path of a list under such a param, and under any other param an absolute path +/// or a `file:` URL ([`unreadable_absolute`]). fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { - params.retain(|key, v| !(is_path_param(key) && v.as_str().is_some_and(|p| !readable(p)))); - if let Some(Value::Array(paths)) = params.get_mut("paths") { - paths.retain(|p| p.as_str().is_none_or(readable)); - } - for v in params.values_mut() { + use vectorcraft_engine::file_access::is_path_key; + params.retain(|key, v| match v.as_str() { + Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + }); + for (key, v) in params.iter_mut() { + if let Value::Array(paths) = v + && is_path_key(key) + { + paths.retain(|p| p.as_str().is_none_or(readable_value)); + } leave_out_unreadable_values(v); } } -/// [`leave_out_unreadable_params`] on each object `v` holds. +/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL +/// the request may not read taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), - Value::Array(items) => items.iter_mut().for_each(leave_out_unreadable_values), + Value::Array(items) => { + items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.iter_mut().for_each(leave_out_unreadable_values); + } _ => {} } } @@ -609,21 +768,45 @@ const DOCKING_ACTION: &str = "action"; /// Runs a control request. A library loaded from outside the read root keeps the id `ui.inspect` /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id -/// in a [`LIBRARY_PARAMS`] param, and the reply to such a request shows that id. +/// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each +/// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, +/// errors included: the libraries hidden after it ran, and before it for a request that names one. +/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { - let ids = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = ids.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); - let mut params = req.params.clone(); - if shown.is_empty() || !swap_library_params(&mut params, &shown) { + if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let named = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - match dispatch(app, ctx, &named) { - Outcome::Done(mut reply) => { - hide_library_ids(&mut reply, &ids); - Outcome::Done(reply) + let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; + let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + let mut params = req.params.clone(); + let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + }; + let Outcome::Done(mut reply) = outcome else { return outcome }; + if !holds_a_separator(&reply) { + return Outcome::Done(reply); + } + let mut ids = unreadable_library_ids(app); + for pair in before { + if !ids.iter().any(|(id, _)| *id == pair.0) { + ids.push(pair); } - outcome => outcome, + } + hide_library_ids(&mut reply, &ids); + Outcome::Done(reply) +} + +/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +fn holds_a_separator(v: &Value) -> bool { + let has = |s: &str| s.contains(['/', '\\']); + match v { + Value::String(s) => has(s), + Value::Array(items) => items.iter().any(holds_a_separator), + Value::Object(fields) => fields.iter().any(|(key, item)| has(key) || holds_a_separator(item)), + _ => false, } } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 1763fd679a..bc1dab6c42 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -12,6 +12,63 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value r } +/// A reply hides a hidden library's id and path at every occurrence, in an error that wraps them in +/// spaces, brackets or quotes, follows them with more path text, or quotes the path with `{:?}`. +#[test] +fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { + let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); + let id = format!("loaded/{path}"); + let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); + let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); +} + +/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; + let data = "QUJDassets/iconREVG"; + let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); + let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); + assert_eq!(reply["result"]["status"], "from icon", "{reply}"); +} + +/// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a +/// relative name and a path inside the root are not. +#[test] +fn an_absolute_path_outside_the_read_root_is_unreadable() { + let base = std::env::temp_dir().join(format!("vc-unreadable-absolute-{}", std::process::id())); + std::fs::create_dir_all(base.join("in")).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let inside = base.join("in").join("a.svg").to_string_lossy().into_owned(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + assert!(super::unreadable_absolute("/outside/root/x")); + assert!(super::unreadable_absolute("file:///outside/root/x")); + assert!(super::unreadable_absolute("FILE:///outside/root/x")); + assert!(super::unreadable_absolute("file://other-host/share/x")); + let url = if inside.starts_with('/') { format!("File://{inside}") } else { format!("File:///{}", inside.replace('\\', "/")) }; + assert!(!super::unreadable_absolute(&url), "{url}"); + assert!(super::readable_value(&url), "{url}"); + let escaped = url.replacen("/in/a.svg", "/%69n/a.svg", 1); + assert!(!super::unreadable_absolute(&escaped) && super::readable_value(&escaped), "{escaped}"); + assert!(super::unreadable_absolute("file:///outside%2Froot/x")); + assert!(!super::unreadable_absolute("")); + assert!(!super::unreadable_absolute("keep")); + assert!(!super::unreadable_absolute(&inside)); + }); + let _ = std::fs::remove_dir_all(base); +} + #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 8f6e0f5ab5..904068cfc8 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -487,22 +487,106 @@ fn ui_inspect_checks_a_folder_preference_as_written() { let _ = std::fs::remove_dir_all(base); } -/// `ui.inspect` rewrites a hidden library's id only in the fields that hold library and panel ids: -/// status text that is that id stays as written. +/// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the +/// command palette query and a script's report show the id and file name shown, and the path appears +/// nowhere in the reply. #[test] -fn ui_inspect_keeps_status_text_that_is_a_hidden_library_id() { +fn ui_inspect_hides_a_hidden_library_path_in_every_string() { let (mut app, tx, base) = confined("inspect-status-id"); - let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + let path = s(base.join("outside/b.svg")); + let theirs = format!("loaded/{path}"); app.ui.color_guide_limit = theirs.clone(); - app.ui.status = theirs.clone(); + app.ui.status = format!("Loaded {path} (3 swatches), kept {path}.bak"); + app.ui.palette_query = format!("\"{theirs}\""); + let source = format!("vc.log({})", json!(format!("read {path}."))); + app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); + for _ in 0..10_000 { + if app.session.script_pump().is_some() { + break; + } + } let r = request(&mut app, &tx, "ui.inspect", json!({})); assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); - assert_eq!(r["result"]["ui"]["status"], json!(theirs), "{r}"); + assert_eq!(r["result"]["ui"]["status"], "Loaded b.svg (3 swatches), kept b.svg.bak", "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "\"loaded/b.svg\"", "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["read b.svg."]), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// The reply to a request that names a hidden library by the id `ui.inspect` shows hides that +/// library's id wherever the reply holds it, also between spaces and brackets and where a character +/// that continues a path follows it. +#[test] +fn a_reply_hides_a_library_the_request_named_by_its_shown_id_everywhere() { + let (mut app, tx, base) = confined("named-hidden-reply"); + let theirs = format!("loaded/{}", s(base.join("outside/b.svg"))); + app.ui.color_guide_limit = theirs.clone(); + app.ui.status = format!("Not added ( {theirs} ); kept {theirs}_old"); + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/b.svg"})); + assert_eq!(r["result"]["ui"]["status"], "Not added ( loaded/b.svg ); kept loaded/b.svg_old", "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error to a request that names a hidden library by the id `ui.inspect` shows names it by that id, +/// and its path appears nowhere in the reply. +#[test] +fn an_error_for_a_hidden_library_named_by_its_shown_id_shows_that_id() { + let (mut app, tx, base) = confined("named-hidden-error"); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside/b.svg"))); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.colorGuideLimit", "params": {"library": "loaded/b.svg"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().is_some_and(|e| e.contains("loaded/b.svg")), "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path outside the read root under any path +/// param (`diffPath`, `file`, a name ending in `Files`), and an absolute path or a `file:` URL outside +/// it (the scheme in any case) under any other param or in any list. A `file:` URL inside it stays. +#[test] +fn ui_inspect_leaves_out_recorded_paths_under_any_key() { + let (mut app, tx, base) = confined("inspect-action-keys"); + let (a, b) = (s(base.join("in/a.svg")), s(base.join("outside/b.svg"))); + let to_url = |p: &str| if p.starts_with('/') { format!("file://{p}") } else { format!("file:///{}", p.replace('\\', "/")) }; + let (url, url_in) = (to_url(&b), to_url(&a)); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["document.compare", {"diffPath": b, "target": a}], + ["file.open", {"file": b}], + ["type.fonts", {"fontFiles": [a, b, url_in]}], + ["x.y", {"target": b, "url": url, "upper": url.replacen("file:", "FILE:", 1), "other": "keep"}], + ["x.z", {"list": [b, "keep"]}], + ["x.w", {"share": "file://other-host/share/b.svg", "other": "keep"}], + ["x.v", {"file": url_in}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([ + ["document.compare", {"target": a}], + ["file.open", {}], + ["type.fonts", {"fontFiles": [a, url_in]}], + ["x.y", {"other": "keep"}], + ["x.z", {"list": ["keep"]}], + ["x.w", {"other": "keep"}], + ["x.v", {"file": url_in}], + ]); + assert_eq!(steps, &want, "{r}"); + let outside = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(outside.trim_matches('"')), "no path outside the root: {r}"); let _ = std::fs::remove_dir_all(base); } /// `ui.inspect` hides a library loaded from outside the read root in the steps of a recorded action -/// (its `library` and `panel` params), also when nothing else names it, and keeps the action's name. +/// (its `library` and `panel` params) and in the action's name, also when nothing else names it. #[test] fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { let (mut app, tx, base) = confined("inspect-action-steps"); @@ -518,7 +602,7 @@ fn ui_inspect_hides_a_hidden_library_in_recorded_steps() { app.ui.action_sets = vec![set]; let r = request(&mut app, &tx, "ui.inspect", json!({})); let action = &r["result"]["ui"]["action_sets"][0]["actions"][0]; - assert_eq!(action["name"], json!(theirs), "{r}"); + assert_eq!(action["name"], "loaded/b.svg", "{r}"); assert_eq!( action["steps"], json!([["swatch.library.add", {"library": "loaded/b.svg"}], ["window.panel", {"panel": "library:swatches:loaded/b.svg"}]]), @@ -731,7 +815,7 @@ fn ui_inspect_script_report_leaves_out_files_outside_the_read_root() { /// `ui.inspect` rewrites a hidden library's id only where a string is that whole id: a readable /// library's id that begins with it stays, and a script's report line and a command palette query -/// that hold it stay as written. +/// that end with it show the id shown. #[test] fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let (mut app, tx, base) = confined("inspect-whole-id"); @@ -740,8 +824,7 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { assert!(readable.starts_with(&hidden)); let panel = |id: &str| -> crate::panels::library_panel::LibraryPanel { serde_json::from_value(json!({"kind": "swatches", "id": id})).unwrap() }; app.ui.library_panels = vec![panel(&hidden), panel(&readable)]; - let query = format!("library:swatches:{hidden}"); - app.ui.palette_query = query.clone(); + app.ui.palette_query = format!("library:swatches:{hidden}"); let text = format!("found {hidden}"); let source = format!("vc.log({})", json!(text)); app.session.execute("script.run", &json!({"source": source, "name": "t.js"})).unwrap(); @@ -753,8 +836,12 @@ fn ui_inspect_rewrites_only_whole_hidden_library_ids() { let r = request(&mut app, &tx, "ui.inspect", json!({})); let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); - assert_eq!(r["result"]["scriptReport"]["log"], json!([text]), "{r}"); - assert_eq!(r["result"]["ui"]["palette_query"], json!(query), "{r}"); + assert_eq!(r["result"]["scriptReport"]["log"], json!(["found loaded/i"]), "{r}"); + assert_eq!(r["result"]["ui"]["palette_query"], "library:swatches:loaded/i", "{r}"); + // A request that names the hidden library by the id shown keeps the readable id too. + let r = request(&mut app, &tx, "ui.inspect", json!({"library": "loaded/i"})); + let ids: Vec<_> = r["result"]["ui"]["library_panels"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect(); + assert_eq!(ids, [json!("loaded/i"), json!(readable)], "{r}"); let _ = std::fs::remove_dir_all(base); } diff --git a/docs/mcp.md b/docs/mcp.md index ac035e7cfc..a4e322818f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -143,8 +143,11 @@ the suite) keep the agent to the folders you give it: `file.openRecentN` for a file outside the roots ends with `no such recent file`, as an empty slot does. - `ui_inspect` (the control channel's `ui.inspect`) leaves out the paths outside the read roots: in `opening`, the documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; - in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path` and `folder` params, - a param whose name ends in `Folder` (`destinationFolder`) and the entries of a `paths` param; and the folder + in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, + `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, + `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an + absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names + another host is always left out); and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -159,9 +162,12 @@ the suite) keep the agent to the folders you give it: changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` - reopens its panel), and the reply to such a request, an error included, shows that id too. `ui_inspect` rewrites only whole ids, and only in the fields that hold library and panel ids: - a longer id that begins with a hidden one, the status text (`ui.status`), the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) keep their text. + reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path + appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its + path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query + (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one + keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one + stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From 00f5a16494832948ad73c445cec6988846c5c1b2 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:33:37 -0800 Subject: [PATCH 11/35] MCP: replies hide library paths outside the read roots in more forms - Every request under a read root records the hidden libraries before it runs, and the reply hides those and the ones hidden after it. A panic is answered inside the request, so its error is hidden too. - A hidden path followed by more path text stays only when the longer text is another loaded library's id with no `.` or `..` part. - Encoded file data is skipped only for a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`; objects and lists under those keys, and other keys ending in `Base64`, are searched. - The error of a failed request hides a path outside the read roots that the request sent by its file name, so it reads the same whether or not a library was loaded from that path. - Paths written with `%XX` escapes are hidden, and on Windows paths in another letter case. - Recorded steps in `ui.inspect` leave out `~`, paths under `~` and paths starting with `./` or `../` outside the read roots under any param. - Hidden libraries are numbered in load order and keep their id from one request to the next. --- crates/ui-egui/src/control.rs | 417 ++++++++++++++++++++----- crates/ui-egui/src/control/tests.rs | 126 +++++++- crates/ui-egui/src/tests/automation.rs | 114 ++++++- docs/mcp.md | 18 +- 4 files changed, 575 insertions(+), 100 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index c38baf5165..3fba434993 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -323,38 +323,115 @@ const PANEL_FIELDS: [&str; 7] = /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of /// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of -/// recorded actions' steps; the session lists the libraries it has loaded. Text the person types (a -/// command palette query, a workspace's or an action's name) is not read. The ids are numbered as the -/// library commands number them ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so -/// that two libraries stay two and `ui.inspect`, `ui.docking` and the library commands show a library -/// under the same id. -fn unreadable_library_ids(app: &VectorcraftApp) -> Vec<(String, String)> { - let mut ids = std::collections::BTreeSet::new(); +/// recorded actions' steps. Text the person types (a command palette query, a workspace's or an +/// action's name) is not read. The session's loaded libraries whose file the request may not read are +/// hidden too. The ids are numbered as the library commands number them +/// ([`vectorcraft_engine::cmd::swatchlib::concealed_library_ids`]), so that two libraries stay two and +/// `ui.inspect`, `ui.docking` and the library commands show a library under the same id. +fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { + let mut rest = std::collections::BTreeSet::new(); let ui = serde_json::to_value(&app.ui).unwrap_or_default(); for field in PANEL_FIELDS { - collect_library_panel_ids(&ui[field], &mut ids); + collect_library_panel_ids(&ui[field], &mut rest); } - collect_library_params(&ui["action_sets"], &mut ids); + collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { - collect_library_panel_ids(layout, &mut ids); + collect_library_panel_ids(layout, &mut rest); } } - collect_library_panel_ids(&json!(app.ui.transient_panel), &mut ids); - // The ids of the libraries loaded in the session: a hidden one does not take the id of one loaded - // without a path. - let s = &app.session; - let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); - let loaded: Vec<&str> = loaded.map(|info| info.id.as_str()).collect(); - for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]).chain(loaded.iter().copied()) { + collect_library_panel_ids(&json!(app.ui.transient_panel), &mut rest); + let mut ids = rest; + for id in app.ui.library_panels.iter().map(|p| p.id.as_str()).chain([app.ui.color_guide_limit.as_str()]) { add_library_id(id, &mut ids); } let mut hidden = vectorcraft_engine::cmd::swatchlib::concealed_library_ids(&app.session, ids.iter().cloned()); - // Only the ids the UI state and the session refer to are replaced in the reply. - hidden.retain(|(id, _)| ids.contains(id)); + // The other loaded library ids the session and the UI state hold. + let s = &app.session; + let loaded = s.swatch_libraries.infos().chain(s.style_libraries.infos()).chain(s.symbol_libraries.infos()).chain(s.brush_libraries.infos()); + let readable_ids = loaded + .map(|info| info.id.clone()) + .chain(ids) + .filter(|id| id.len() > "loaded/".len() && id.starts_with("loaded/") && !hidden.iter().any(|(h, _)| h == id)) + .collect(); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - hidden + Hiding { hidden, readable: readable_ids } +} + +/// What a reply hides ([`hide_library_ids`]). +#[derive(Debug, Default)] +struct Hiding { + /// The ids of the loaded libraries the request may not read, longest first, each with the id + /// shown instead ([`unreadable_library_ids`]). + hidden: Vec<(String, String)>, + /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or + /// path that continues into one of them stays ([`known_continuation`]). + readable: std::collections::BTreeSet<String>, +} + +impl Hiding { + /// Adds the hidden and readable ids of `other` that this one does not hold. + fn merge(&mut self, other: Hiding) { + self.add_hidden(other.hidden); + self.readable.extend(other.readable); + } + + /// Adds the hidden ids of `pairs` that this one does not hold, each with the id shown. + fn add_hidden(&mut self, pairs: Vec<(String, String)>) { + for pair in pairs { + if !self.hidden.iter().any(|(id, _)| *id == pair.0) { + self.hidden.push(pair); + } + } + self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + } + + /// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for + /// the same path: written with either separator, and in any case of ASCII letters with `fold`. + fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { + let key = |id: &str| { + let id = id.replace('\\', "/"); + if fold { id.to_ascii_lowercase() } else { id } + }; + let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); + self.hidden.retain(|(id, _)| !sent.contains(&key(id))); + self.add_hidden(pairs); + } +} + +/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths +/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/<path>`) with `loaded/<file name>` +/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file +/// name also for a hidden library shown as `loaded/<file name> (2)`, so that it reads the same whether +/// or not a library was loaded from one of them. +fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { + fn add(s: &str, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) { + return; + } + let path = match file_url_path(s) { + Some(Some(path)) => path, + Some(None) => return, + None => s.to_string(), + }; + let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let pair = (format!("loaded/{path}"), format!("loaded/{name}")); + if !out.contains(&pair) { + out.push(pair); + } + } + match v { + Value::String(s) => add(s, out), + Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::Object(fields) => { + for (key, item) in fields { + add(key, out); + sent_paths(item, out); + } + } + _ => {} + } } /// Adds `id` to `ids` when it is a loaded library's id (`loaded/<path>`). @@ -389,15 +466,20 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id /// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's /// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the request may read the longer path -/// ([`readable_continuation`]): a readable library's id that begins with a hidden one keeps its path. -fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - replace_in_value(v, &hidden_text(ids)); +/// ([`continues_path`]) stays only when the longer text is another loaded library's id +/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// Windows the ids and paths are matched in any case of ASCII letters. +fn hide_library_ids(v: &mut Value, hiding: &Hiding) { + let pairs = hidden_text(&hiding.hidden); + if pairs.is_empty() { + return; + } + replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); } /// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and with each -/// `\` doubled (a path quoted with `{:?}`). +/// path in it with the file name shown, the path written with either separator too, and with each `\` +/// doubled (a path quoted with `{:?}`). fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { let mut pairs: Vec<(String, String)> = Vec::new(); for (id, shown) in ids { @@ -416,49 +498,174 @@ fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { pairs } -/// Writes each `from` text of `pairs` as its `to` text in every string and key of `v`, at each -/// occurrence not followed by a character that continues a path ([`continues_path`]) into a path the -/// request may read ([`readable_continuation`]). Encoded file data, under a key ending in `Base64` -/// (`dataBase64`, `pngBase64`), stays as it is. -fn replace_in_value(v: &mut Value, pairs: &[(String, String)]) { - if pairs.is_empty() { - return; - } +/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match +/// in any case of ASCII letters. +struct Rewrite<'a> { + pairs: Vec<(String, String)>, + hiding: &'a Hiding, + fold: bool, +} + +/// The keys of encoded file data in a reply. +const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"]; + +/// Is `s` written in the base64 alphabet, padded to a multiple of 4? +fn is_base64(s: &str) -> bool { + let body = s.trim_end_matches('='); + s.len().is_multiple_of(4) && s.len() - body.len() <= 2 && body.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/') +} + +/// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as +/// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays +/// as it is. +fn replace_in_value(v: &mut Value, r: &Rewrite) { match v { Value::String(s) => { - if let Some(replaced) = replace_text(s, pairs) { + if let Some(replaced) = replace_text(s, r) { *s = replaced; } } - Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, pairs)), + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), Value::Object(fields) => { for (key, mut item) in std::mem::take(fields) { - if key.ends_with("Base64") { + if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) { fields.insert(key, item); continue; } - replace_in_value(&mut item, pairs); - fields.insert(replace_text(&key, pairs).unwrap_or(key), item); + replace_in_value(&mut item, r); + fields.insert(replace_text(&key, r).unwrap_or(key), item); } } _ => {} } } -/// `s` with the `from` texts of `pairs` written as their `to` texts, as [`replace_in_value`] writes -/// them. `None` when nothing changed. -fn replace_text(s: &str, pairs: &[(String, String)]) -> Option<String> { +/// The byte offsets in `text` at which `needle` starts, not overlapping, in any case of ASCII letters +/// with `fold`. +fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { + if needle.is_empty() { + return Vec::new(); + } + if fold { + let (text, needle) = (text.to_ascii_lowercase(), needle.to_ascii_lowercase()); + text.match_indices(needle.as_str()).map(|(at, _)| at).collect() + } else { + text.match_indices(needle).map(|(at, _)| at).collect() + } +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence +/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id +/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// replaced too; the escapes around it stay. `None` when nothing changed. +fn replace_text(s: &str, r: &Rewrite) -> Option<String> { + let replaced = replace_plain_text(s, r); + let text = replaced.as_deref().unwrap_or(s); + if !text.contains('%') { + return replaced; + } + let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced }; + if decoded == text { + return replaced; + } + // The spans of `text` to replace, found in the decoded text, longest `from` first. + let mut cuts: Vec<(usize, usize, &str)> = Vec::new(); + let mut taken: Vec<(usize, usize)> = Vec::new(); + for (from, to) in &r.pairs { + for at in occurrences(&decoded, from, r.fold) { + let end = at + from.len(); + let free = !taken.iter().any(|&(a, b)| at < b && a < end); + if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + taken.push((at, end)); + cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); + } + } + } + if cuts.is_empty() { + return replaced; + } + cuts.sort_unstable_by_key(|&(start, _, _)| start); + let mut out = String::with_capacity(text.len()); + let mut copied = 0; + for (start, stop, to) in cuts { + out.push_str(&text[copied..start]); + out.push_str(to); + copied = stop; + } + out.push_str(&text[copied..]); + Some(out) +} + +/// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` +/// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. +fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { + let bytes = text.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut spans = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + let hex = bytes + .get(i + 1..i + 3) + .filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit)) + .and_then(|h| std::str::from_utf8(h).ok()) + .and_then(|h| u8::from_str_radix(h, 16).ok()); + match hex { + Some(v) => { + out.push(v); + spans.push((i, i + 3)); + i += 3; + } + None => { + out.push(bytes[i]); + spans.push((i, i + 1)); + i += 1; + } + } + } + let mut decoded = String::with_capacity(out.len()); + let mut decoded_spans = Vec::with_capacity(spans.len()); + let mut at = 0; + for chunk in out.utf8_chunks() { + decoded.push_str(chunk.valid()); + decoded_spans.extend_from_slice(spans.get(at..at + chunk.valid().len())?); + at += chunk.valid().len(); + for _ in chunk.invalid() { + decoded.push(char::REPLACEMENT_CHARACTER); + let span = *spans.get(at)?; + decoded_spans.extend([span; 3]); + at += 1; + } + } + Some((decoded, decoded_spans)) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`] +/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written +/// are replaced first, so that two libraries whose paths differ only in letter case keep their own +/// ids, and then the ones written in another case. `None` when nothing changed. +fn replace_plain_text(s: &str, r: &Rewrite) -> Option<String> { + let exact = replace_matches(s, r, false); + if !r.fold { + return exact; + } + replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact) +} + +/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case +/// of ASCII letters with `fold`. `None` when nothing changed. +fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { let mut out: Option<String> = None; - for (from, to) in pairs { + for (from, to) in &r.pairs { let text = out.as_deref().unwrap_or(s); - if from.is_empty() || !text.contains(from.as_str()) { + let found = occurrences(text, from, fold); + if found.is_empty() { continue; } let mut next = String::with_capacity(text.len()); let mut copied = 0; - for (at, _) in text.match_indices(from.as_str()) { + for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !readable_continuation(&text[at..]) { + if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -478,13 +685,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// May the request read the longer path that `text` starts with, up to a space, a quote, a bracket, -/// `,`, `;` or `|`, without a `loaded/` before it? A readable library's id that begins with a hidden -/// one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. -fn readable_continuation(text: &str) -> bool { - let end = text.find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).unwrap_or(text.len()); - let path = &text[..end]; - readable(path.strip_prefix("loaded/").unwrap_or(path)) +/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, +/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written +/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a +/// hidden one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. +fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); + let longer = &text[at..stop]; + if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { + return false; + } + let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; + let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; + r.hiding.readable.iter().any(|known| same(known, &id)) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a @@ -584,6 +797,19 @@ fn unreadable_absolute(s: &str) -> bool { } } +/// Is `s` written as a path that the request may not read: an absolute path or a `file:` URL +/// ([`unreadable_absolute`]), `~` or a path under it, or a path that starts with `.` or `..` and a +/// separator? It is checked as the file access check reads it, relative to the working folder. Other +/// text with a separator (`a/b`) stays. +fn unreadable_path_like(s: &str) -> bool { + if unreadable_absolute(s) { + return true; + } + let t = s.trim_start(); + let relative = t.trim_end() == "~" || ["~/", "~\\", "./", ".\\", "../", "..\\"].iter().any(|p| t.starts_with(p)); + relative && !readable_value(s) +} + /// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? fn readable_value(s: &str) -> bool { match file_url_path(s) { @@ -646,13 +872,16 @@ fn steps_mut(set: &mut Value) -> impl Iterator<Item = &mut serde_json::Map<Strin /// Takes out of a recorded step's params, at any depth (the commands of a `command.batch` step), the /// paths the request may not read: the value of a path param /// ([`vectorcraft_engine::file_access::is_path_key`]: `path`, `file`, `diffPath`, `destinationFolder` -/// and the like), each path of a list under such a param, and under any other param an absolute path -/// or a `file:` URL ([`unreadable_absolute`]). +/// and the like), each path of a list under such a param, and under any other param, as a key too, a +/// string written as a path ([`unreadable_path_like`]). fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { use vectorcraft_engine::file_access::is_path_key; - params.retain(|key, v| match v.as_str() { - Some(p) => !unreadable_absolute(p) && !(is_path_key(key) && !readable_value(p)), - None => true, + params.retain(|key, v| { + !unreadable_path_like(key) + && match v.as_str() { + Some(p) => !unreadable_path_like(p) && !(is_path_key(key) && !readable_value(p)), + None => true, + } }); for (key, v) in params.iter_mut() { if let Value::Array(paths) = v @@ -664,13 +893,13 @@ fn leave_out_unreadable_params(params: &mut serde_json::Map<String, Value>) { } } -/// [`leave_out_unreadable_params`] on each object `v` holds, and an absolute path or a `file:` URL -/// the request may not read taken out of each list. +/// [`leave_out_unreadable_params`] on each object `v` holds, and a string written as a path that the +/// request may not read ([`unreadable_path_like`]) taken out of each list. fn leave_out_unreadable_values(v: &mut Value) { match v { Value::Object(fields) => leave_out_unreadable_params(fields), Value::Array(items) => { - items.retain(|item| !item.as_str().is_some_and(unreadable_absolute)); + items.retain(|item| !item.as_str().is_some_and(unreadable_path_like)); items.iter_mut().for_each(leave_out_unreadable_values); } _ => {} @@ -770,38 +999,58 @@ const DOCKING_ACTION: &str = "action"; /// shows for it ([`unreadable_library_ids`]) both ways: a request may name it or its panel by that id /// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, -/// errors included: the libraries hidden after it ran, and before it for a request that names one. -/// A reply with no `/` or `\` in it is not searched, since every hidden path holds a separator. +/// errors included, an error from a panic too: the libraries hidden before it ran and after it. The +/// error of a failed request also hides the paths outside the read root that the request sent +/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent +/// after the tool's edit) holds no library id or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); } - let before = if names_a_loaded_library(&req.params) { unreadable_library_ids(app) } else { Vec::new() }; - let shown: Vec<_> = before.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); + // Pointer, key and text input does not load, close or rename a library: the libraries hidden after + // it ran are the ones hidden before, so frequent input does not read the UI state twice. + let before = if INPUT_METHODS.contains(&req.method.as_str()) { Hiding::default() } else { unreadable_library_ids(app) }; + let shown: Vec<_> = before.hidden.iter().map(|(id, shown)| (shown.as_str(), id.as_str())).collect(); let mut params = req.params.clone(); - let outcome = if !shown.is_empty() && swap_library_params(&mut params, &shown) { - let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; - dispatch(app, ctx, &request) - } else { - dispatch(app, ctx, req) + let swapped = !shown.is_empty() && names_a_loaded_library(&req.params) && swap_library_params(&mut params, &shown); + let ran = vectorcraft_engine::guard::catch_panic(|| { + if swapped { + let request = ControlRequest { method: req.method.clone(), params, reply: req.reply.clone(), state: req.state.clone(), seq: req.seq }; + dispatch(app, ctx, &request) + } else { + dispatch(app, ctx, req) + } + }); + let outcome = match ran { + Ok(outcome) => outcome, + Err(msg) => { + app.picks.recover(); + app.session.set_replaying(false); + err(format!("internal error: {msg} (please report this bug)")) + } }; let Outcome::Done(mut reply) = outcome else { return outcome }; if !holds_a_separator(&reply) { return Outcome::Done(reply); } - let mut ids = unreadable_library_ids(app); - for pair in before { - if !ids.iter().any(|(id, _)| *id == pair.0) { - ids.push(pair); - } + let mut hiding = unreadable_library_ids(app); + hiding.merge(before); + if reply.get("ok").and_then(Value::as_bool) == Some(false) { + let mut sent = Vec::new(); + sent_paths(&req.params, &mut sent); + hiding.set_hidden(sent, cfg!(windows)); } - hide_library_ids(&mut reply, &ids); + hide_library_ids(&mut reply, &hiding); Outcome::Done(reply) } -/// Does a string or a key of `v`, at any depth, hold a `/` or a `\`? +/// The control methods that send synthetic input. +const INPUT_METHODS: [&str; 7] = ["ui.pointer", "ui.click", "ui.drag", "ui.move", "ui.wheel", "ui.key", "ui.text"]; + +/// Does a string or a key of `v`, at any depth, hold a `/`, a `\` or a `%`? fn holds_a_separator(v: &Value) -> bool { - let has = |s: &str| s.contains(['/', '\\']); + let has = |s: &str| s.contains(['/', '\\', '%']); match v { Value::String(s) => has(s), Value::Array(items) => items.iter().any(holds_a_separator), @@ -879,6 +1128,18 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) hide_library_ids(&mut docking, &ids); ok(docking) } + // A request that stops with a panic quoting the status text, after clearing the Color Guide's + // limit with `forget`. + #[cfg(test)] + "test.panic" => { + if p.get("forget").and_then(Value::as_bool) == Some(true) { + app.ui.color_guide_limit.clear(); + } + panic!("failed on {}", app.ui.status) + } + // A request that succeeds with the status text. + #[cfg(test)] + "test.status" => ok(json!(app.ui.status)), "ui.menu.list" => ok(serde_json::to_value(crate::menus::menu_entries(app)).unwrap_or_default()), "ui.contextMenu.list" => ok(serde_json::to_value(crate::menus::context_entries(app)).unwrap_or_default()), "ui.tool.select" => wrap(app.run("tool.select", json!({"tool": s("tool").unwrap_or("")}))), diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index bc1dab6c42..d06f9eb83e 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -18,29 +18,125 @@ fn input_request(app: &mut VectorcraftApp, method: &str, params: Value) -> Value fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { let path = std::env::temp_dir().join("vc-named-reply").join("b.svg").to_string_lossy().into_owned(); let id = format!("loaded/{path}"); - let hidden = [(id.clone(), "loaded/b.svg".to_string())]; + let hidden = hiding(&[(&id, "loaded/b.svg")], &[]); let mut reply = json!({"ok": false, "error": format!("can't add ( {id} ) or '{id}', from {path}; also {id}.bak and {path:?}")}); - let root = std::env::temp_dir().join(format!("vc-named-reply-root-{}", std::process::id())); - std::fs::create_dir_all(&root).unwrap(); - let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); - let _ = std::fs::remove_dir_all(root); + hide_under_a_root("named-reply", &mut reply, &hidden); assert_eq!(reply["error"], "can't add ( loaded/b.svg ) or 'loaded/b.svg', from b.svg; also loaded/b.svg.bak and \"b.svg\"", "{reply}"); } -/// Encoded file data in a reply (`dataBase64`) stays as it is when it holds a hidden library's path. -#[test] -fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { - let hidden = [("loaded/assets/icon".to_string(), "loaded/icon".to_string())]; - let data = "QUJDassets/iconREVG"; - let mut reply = json!({"ok": true, "result": {"files": [{"name": "a.png", "dataBase64": data}], "status": "from assets/icon"}}); - let root = std::env::temp_dir().join(format!("vc-base64-root-{}", std::process::id())); +/// A [`super::Hiding`] with `hidden` ids, each with its shown id, and `readable` ids. +fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { + let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); + hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); + super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() } +} + +/// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. +fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { + let root = std::env::temp_dir().join(format!("vc-{tag}-root-{}", std::process::id())); std::fs::create_dir_all(&root).unwrap(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); - vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(reply, hidden)); let _ = std::fs::remove_dir_all(root); - assert_eq!(reply["result"]["files"][0]["dataBase64"], data, "{reply}"); +} + +/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or +/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64 +/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched. +#[test] +fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { + let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); + let data = "QUJDassets/iconREVGxQQ=="; + let mut reply = json!({"ok": true, "result": { + "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], + "status": "from assets/icon", + "dataBase64": {"note": "from assets/icon"}, + "pngBase64": ["from assets/icon"], + "otherBase64": data, + }}); + hide_under_a_root("base64", &mut reply, &hidden); + let files = &reply["result"]["files"][0]; + assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}"); + assert_eq!(files["pngBase64"], "icon.png", "{reply}"); assert_eq!(reply["result"]["status"], "from icon", "{reply}"); + assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); + assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); + assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}"); +} + +/// A hidden path followed by more text stays only when the longer text is another loaded library's +/// id: text after the path that the request may read, a `..` part and the part of the path before a +/// space do not keep it. +#[test] +fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { + let base = std::env::temp_dir().join(format!("vc-continuation-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let hidden_path = base.join("in x").join("b.svg").to_string_lossy().into_owned(); + let other = base.join("outside").join("b.svg").to_string_lossy().into_owned(); + let known = format!("loaded/{other}{}sub.svg", std::path::MAIN_SEPARATOR); + let hidden = hiding(&[(&format!("loaded/{hidden_path}"), "loaded/b.svg"), (&format!("loaded/{other}"), "loaded/b.svg (2)")], &[&known]); + let up = ["", "..", "..", "in", "a.svg"].join(std::path::MAIN_SEPARATOR_STR); + let mut reply = json!({"error": format!("kept {hidden_path}.bak, {other}{up} and {known}")}); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); + let _ = std::fs::remove_dir_all(base); + assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); +} + +/// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden +/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform. +#[test] +fn a_reply_hides_a_path_in_any_case_and_escaped() { + let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); + let pairs = super::hidden_text(&hidden.hidden); + let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; + super::replace_in_value(&mut reply, &rewrite); + assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); + // Two libraries whose paths differ only in letter case keep their own ids. + let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); + let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); + assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); + hide_under_a_root("escaped", &mut reply, &hidden); + assert_eq!(reply["error"], "file:///b.svg", "{reply}"); + let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"}); + hide_under_a_root("escaped-mixed", &mut reply, &hidden); + assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); +} + +/// A path a failed request sent takes the place of a hidden library's id for the same path, written +/// with the other separator, and in another letter case when matching in any case (as on Windows). +#[test] +fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { + let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); + hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); + let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect(); + assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]); + hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false); + assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); +} + +/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or +/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and +/// a path inside the root are not. +#[test] +fn a_request_sends_paths_that_an_error_hides() { + let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); + let root = base.join("in"); + std::fs::create_dir_all(&root).unwrap(); + let inside = root.join("a.svg").to_string_lossy().into_owned(); + let keep = json!(["a/b", inside, "file://host/g.svg"]); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let mut sent = Vec::new(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); + let _ = std::fs::remove_dir_all(base); + let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); + assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 904068cfc8..efa2fce9d0 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -675,6 +675,118 @@ fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { let _ = std::fs::remove_dir_all(base); } +/// Hidden libraries with the same file name are numbered in the order they were loaded, not in the +/// order of their paths. +#[test] +fn hidden_libraries_are_numbered_in_load_order() { + let (mut app, tx, base) = confined("hidden-load-order"); + let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; + for dir in ["z", "a"] { + std::fs::create_dir_all(base.join("outside").join(dir)).unwrap(); + let path = base.join("outside").join(dir).join("b.gpl"); + std::fs::write(&path, gpl).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); + } + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A hidden library keeps the id a reply showed for it when another library with the same file name is +/// loaded later. +#[test] +fn a_hidden_library_keeps_its_id_when_another_with_its_name_loads() { + let (mut app, tx, base) = confined("hidden-stable-id"); + let first = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + std::fs::create_dir_all(base.join("outside").join("z")).unwrap(); + let path = base.join("outside").join("z").join("b.gpl"); + std::fs::write(&path, "GIMP Palette\nName: p\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + app.ui.color_guide_limit = format!("loaded/{}", s(path)); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/b.gpl", "{r}"); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error from a panic hides a hidden library's path, also when the request stopped referring to +/// the library before it panicked. +#[test] +fn a_panic_reply_hides_a_library_hidden_before_the_request() { + let (mut app, tx, base) = confined("panic-hidden"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let r = request(&mut app, &tx, "test.panic", json!({"forget": true})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// An error shows a path outside the read root that the request sent by its file name, whether or not +/// a hidden library was loaded from that path, so that it reads the same either way. A path the +/// request sends does not show the app's own text that holds it, in an error or a result. +#[test] +fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { + let (mut app, tx, base) = confined("client-echo"); + let path = s(base.join("outside/b.svg")); + // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. + let first = format!("loaded/{}", s(base.join("outside/z/b.svg"))); + let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); + app.ui.library_panels = vec![panel]; + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("Loaded {path}"); + let unloaded = [(s(base.join("outside/y/b.svg")), "b.svg"), (s(base.join("outside/c.svg")), "c.svg")]; + for (sent, name) in [(path.clone(), "b.svg")].into_iter().chain(unloaded) { + let r = request(&mut app, &tx, "app.open", json!({"path": sent})); + assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); + } + let r = request(&mut app, &tx, "test.status", json!({"guess": path})); + assert_eq!(r["result"], "Loaded b.svg (2)", "{r}"); + let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); + assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped +/// shows the file name. +#[test] +fn a_reply_hides_a_path_with_escaped_separators() { + let (mut app, tx, base) = confined("escaped-separators"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from file:b.svg", "{r}"); + let _ = std::fs::remove_dir_all(base); +} + +/// `ui.inspect` leaves out of a recorded action's steps a path that starts with `./` or `../`, `~` and +/// a path under `~` outside the read root under any param, as a key too, and in any list. A URL and +/// other text with a separator stay. +#[test] +fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { + let (mut app, tx, base) = confined("inspect-relative"); + let set: crate::panels::actions::ActionSet = serde_json::from_value(json!({ + "name": "set", + "actions": [{"name": "files", "steps": [ + ["x.y", {"target": "~/b.svg", "rel": "../outside/b.svg", "home": "~", "text": "a/b", "url": "https://example.com/a"}], + ["x.z", {"list": [".\\b.svg", "a/b"], "../k": 1}], + ]}], + })) + .unwrap(); + app.ui.action_sets = vec![set]; + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let steps = &r["result"]["ui"]["action_sets"][0]["actions"][0]["steps"]; + let want = json!([["x.y", {"text": "a/b", "url": "https://example.com/a"}], ["x.z", {"list": ["a/b"]}]]); + assert_eq!(steps, &want, "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -735,7 +847,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { let ui = &r["result"]["ui"]; assert_eq!( (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), - (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), + (Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")), "{r}" ); let d = request(&mut app, &tx, "ui.docking", json!({})); diff --git a/docs/mcp.md b/docs/mcp.md index a4e322818f..0399e7a8d9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -145,9 +145,10 @@ the suite) keep the agent to the folders you give it: documents still being read; in `ui.recent_files`, the recent files; a library panel's `path` in `ui.library_panels`; in a recorded action's steps in `ui.action_sets`, also in a batch step's commands, the `path`, `paths`, `file`, `files` and `folder` params, a param whose name ends in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, - `destinationFolder`), the entries of a list under such a param, and under any other param or in any list an - absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that names - another host is always left out); and the folder + `destinationFolder`), the entries of a list under such a param, and under any other param, as a key or in any + list, an absolute path or a `file:` URL outside the roots (any case of `file:`, `%XX` escapes decoded; a URL that + names another host is always left out), and `~`, a path under `~` or a path that starts with `./` or `../` + (either separator) that resolves outside the roots from the app's working folder; and the folder preferences in `ui.engine_prefs` and in an open Preferences dialog's `ui.dialog.fields` (`fontsFolder`, `pluginsFolder`, `scratchPrimary`, `scratchSecondary`, `recoveryFolder`, `templatesFolder`; an empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the @@ -165,9 +166,14 @@ the suite) keep the agent to the folders you give it: reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`) and a script's report (`scriptReport`) included. A longer path that begins with a hidden one - keeps its text only when it lies inside the read roots, so a readable library's id that begins with a hidden one - stays. Encoded file data (`dataBase64`, `pngBase64`) is sent as it is. + (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the + libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path + keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable + library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it, so that the + error reads the same either way. Encoded file data, a base64 string under `dataBase64`, + `pngBase64` or `thumbnailBase64`, is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From 38184805b4aad36e3faa48611f6a7a7786034354 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 16:43:49 -0800 Subject: [PATCH 12/35] MCP: a failed request's error hides a file URL it sent that names another host The error shows the URL's file name, as it does for a local path the request sent outside the read roots. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3fba434993..55f9ec68e6 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -410,9 +410,10 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { if !unreadable_path_like(s) { return; } + // A URL that names another host as the path after its scheme (`//host/share/…`). let path = match file_url_path(s) { Some(Some(path)) => path, - Some(None) => return, + Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d06f9eb83e..e3a129f824 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -120,9 +120,9 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); } -/// The paths outside the read root that a request sends, as a path, a `file:` URL, under `~` or -/// starting with `../`, are hidden by their file names; a URL that names another host, plain text and -/// a path inside the root are not. +/// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names +/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a +/// path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); @@ -130,13 +130,20 @@ fn a_request_sends_paths_that_an_error_hides() { std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "keep": keep}); + let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); - assert_eq!(sent, [pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), pair("/x/my lib/b.svg", "b.svg")]); + let want = [ + pair("/y/c d.svg", "c d.svg"), + pair("~/e.svg", "e.svg"), + pair("../f.svg", "f.svg"), + pair("//host/g.svg", "g.svg"), + pair("/x/my lib/b.svg", "b.svg"), + ]; + assert_eq!(sent, want); } /// Under a read root, an absolute path or a `file:` URL outside it is unreadable; an empty string, a From 13bee0d6ef81f9e0d1a247e187a2f3cd6bdc19d8 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:07:30 -0800 Subject: [PATCH 13/35] MCP: a readable library id keeps its text when a space follows the hidden part A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, also when that id has a space after the hidden part. --- crates/ui-egui/src/control.rs | 41 ++++++++++++++--------------- crates/ui-egui/src/control/tests.rs | 6 +++++ docs/mcp.md | 5 ++-- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 55f9ec68e6..864800e660 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -366,7 +366,7 @@ struct Hiding { /// shown instead ([`unreadable_library_ids`]). hidden: Vec<(String, String)>, /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or - /// path that continues into one of them stays ([`known_continuation`]). + /// path that begins one of them stays ([`begins_known_id`]). readable: std::collections::BTreeSet<String>, } @@ -466,9 +466,8 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Writes each id of `ids` as the id the reply shows instead, and the path in it as the file name /// shown, wherever `v` holds them, in every string and key: a library's id, its panel's id /// (`library:swatches:loaded/<path>`), status text, a dialog, the command palette query, a script's -/// report and a recorded step. An occurrence followed by a character that continues a path -/// ([`continues_path`]) stays only when the longer text is another loaded library's id -/// ([`known_continuation`]): a readable library's id that begins with a hidden one keeps its path. On +/// report and a recorded step. An occurrence that begins another loaded library's id stays +/// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { let pairs = hidden_text(&hiding.hidden); @@ -556,8 +555,8 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { } /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence -/// not followed by a character that continues a path ([`continues_path`]) into another loaded library's id -/// ([`known_continuation`]). A hidden id or path written with `%XX` escapes (in a `file:` URL) is +/// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written +/// with `%XX` escapes (in a `file:` URL) is /// replaced too; the escapes around it stay. `None` when nothing changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { let replaced = replace_plain_text(s, r); @@ -576,7 +575,7 @@ fn replace_text(s: &str, r: &Rewrite) -> Option<String> { for at in occurrences(&decoded, from, r.fold) { let end = at + from.len(); let free = !taken.iter().any(|&(a, b)| at < b && a < end); - if free && (!continues_path(&decoded[end..]) || !known_continuation(&decoded, at, end, r)) { + if free && !begins_known_id(&decoded, at, end, r) { taken.push((at, end)); cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); } @@ -666,7 +665,7 @@ fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { let mut copied = 0; for at in found { let end = at + from.len(); - if !continues_path(&text[end..]) || !known_continuation(text, at, end, r) { + if !begins_known_id(text, at, end, r) { next.push_str(&text[copied..at]); next.push_str(to); copied = end; @@ -686,19 +685,19 @@ fn continues_path(rest: &str) -> bool { rest.trim_start_matches(['.', '-', '~']).starts_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '/' | '\\')) } -/// Is the longer text that the occurrence `text[at..end]` starts, up to the first space, quote, -/// bracket, `,`, `;` or `|` after `end`, another loaded library's id ([`Hiding::readable`]), written -/// with or without its `loaded/`, with no `.` or `..` part? A readable library's id that begins with a -/// hidden one stays; a hidden path followed by more path text (`<path>.bak`) is still hidden. -fn known_continuation(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { - let stop = text[end..].find(|c: char| c.is_whitespace() || "\"'`()[]{}<>,;|".contains(c)).map_or(text.len(), |n| end + n); - let longer = &text[at..stop]; - if longer.split(['/', '\\']).any(|part| part == "." || part == "..") { - return false; - } - let same = |id: &str, want: &str| if r.fold { id.eq_ignore_ascii_case(want) } else { id == want }; - let id = if longer.starts_with("loaded/") { longer.to_string() } else { format!("loaded/{longer}") }; - r.hiding.readable.iter().any(|known| same(known, &id)) +/// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written +/// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops +/// continuing a path ([`continues_path`])? +/// A readable library's id that begins with a hidden one stays, also with a space after the hidden +/// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden. +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { + let rest = &text[at..]; + let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; + let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { + let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) + }) } /// Text that a person or a script wrote, by its key: a command palette query, a script's report and a diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index e3a129f824..026b51bed6 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -82,6 +82,12 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { vectorcraft_engine::file_access::confine(roots.as_ref(), || super::hide_library_ids(&mut reply, &hidden)); let _ = std::fs::remove_dir_all(base); assert_eq!(reply["error"], json!(format!("kept b.svg.bak, b.svg (2){up} and {known}")), "{reply}"); + // A readable library's id with a space after the hidden part stays whole. + // A readable id with a `..` part does not keep the hidden part. + let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg", "loaded//art/in/../ok/c.svg"]); + let mut reply = json!({"error": "x loaded//art/in work/a.svg, /art/in work/a.svg and /art/in work; /art/in/../ok/c.svg"}); + hide_under_a_root("space-continuation", &mut reply, &hidden); + assert_eq!(reply["error"], "x loaded//art/in work/a.svg, /art/in work/a.svg and in work; in/../ok/c.svg", "{reply}"); } /// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden diff --git a/docs/mcp.md b/docs/mcp.md index 0399e7a8d9..d53ee2289d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -168,9 +168,8 @@ the suite) keep the agent to the folders you give it: path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` - URL writes it, and on Windows in any case of its letters. A longer text that begins with a hidden id or path - keeps it only when the longer text is another loaded library's id with no `.` or `..` part, so a readable - library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the + URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the error reads the same either way. Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. From 61771e4ead0d72e60b5de5ea3736be35e2445d36 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:22:51 -0800 Subject: [PATCH 14/35] MCP: a screenshot's reply hides library paths outside the read roots The reply is sent after the request was handled, and now hides what any other reply hides. --- crates/ui-egui/src/app/screenshot.rs | 9 ++++--- crates/ui-egui/src/control.rs | 34 +++++++++++++++++++------- crates/ui-egui/src/tests/automation.rs | 20 +++++++++++++++ docs/mcp.md | 4 +-- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index ce97fc747f..c34d022e50 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -157,9 +157,12 @@ impl VectorcraftApp { let pending = self.pending_screenshots.remove(index); self.screenshot_reserved = self.screenshot_reserved.saturating_sub(pending.reserved_bytes); let roots = self.automation_roots.clone(); - let _ = pending - .reply - .send(file_access::confine(roots.as_ref(), || control::save_screenshot(self, &image, pending.path.as_deref(), pending.data))); + let reply = file_access::confine(roots.as_ref(), || { + let mut reply = control::save_screenshot(self, &image, pending.path.as_deref(), pending.data); + control::hide_in_deferred_reply(self, &mut reply, &json!({"path": pending.path})); + reply + }); + let _ = pending.reply.send(reply); } Err(e) => { let error = format!("the screenshot could not be used: {e}"); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 864800e660..ee24d563bf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1002,8 +1002,9 @@ const DOCKING_ACTION: &str = "action"; /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The /// error of a failed request also hides the paths outside the read root that the request sent /// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path -/// holds a separator, written or escaped. A deferred reply (a screenshot, a `ui.pointer` reply sent -/// after the tool's edit) holds no library id or path. +/// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent +/// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id +/// or path. pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { if vectorcraft_engine::file_access::current().is_none() { return dispatch(app, ctx, req); @@ -1031,18 +1032,33 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } }; let Outcome::Done(mut reply) = outcome else { return outcome }; - if !holds_a_separator(&reply) { - return Outcome::Done(reply); + hide_in_reply(app, &mut reply, &req.params, before); + Outcome::Done(reply) +} + +/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths +/// outside the read root that `sent`, the request's params, holds ([`handle`]). +fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { + if !holds_a_separator(reply) { + return; } let mut hiding = unreadable_library_ids(app); hiding.merge(before); if reply.get("ok").and_then(Value::as_bool) == Some(false) { - let mut sent = Vec::new(); - sent_paths(&req.params, &mut sent); - hiding.set_hidden(sent, cfg!(windows)); + let mut paths = Vec::new(); + sent_paths(sent, &mut paths); + hiding.set_hidden(paths, cfg!(windows)); + } + hide_library_ids(reply, &hiding); +} + +/// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a +/// reply, with `sent` as the request's params. Without a read root in force it leaves the reply as +/// it is. +pub(crate) fn hide_in_deferred_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value) { + if vectorcraft_engine::file_access::current().is_some() { + hide_in_reply(app, reply, sent, Hiding::default()); } - hide_library_ids(&mut reply, &hiding); - Outcome::Done(reply) } /// The control methods that send synthetic input. diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index efa2fce9d0..68717c3f8d 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -752,6 +752,26 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let _ = std::fs::remove_dir_all(base); } +/// A screenshot's reply, sent after its request was handled, hides a hidden library's path, and in an +/// error the path outside the read root that the request sent. +#[test] +fn a_screenshot_reply_hides_paths_like_any_reply() { + let (mut app, _tx, base) = confined("deferred-reply"); + let path = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{path}"); + let sent = s(base.join("outside/shot.png")); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let mut done = json!({"ok": true, "result": {"note": format!("from {path}")}}); + let mut failed = json!({"ok": false, "error": format!("could not write {sent}")}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + crate::control::hide_in_deferred_reply(&app, &mut done, &json!({"path": sent})); + crate::control::hide_in_deferred_reply(&app, &mut failed, &json!({"path": sent})); + }); + assert_eq!(done["result"]["note"], "from b.svg", "{done}"); + assert_eq!(failed["error"], "could not write shot.png", "{failed}"); + let _ = std::fs::remove_dir_all(base); +} + /// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped /// shows the file name. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index d53ee2289d..b4cfad7e5f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -166,8 +166,8 @@ the suite) keep the agent to the folders you give it: reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query - (`ui.palette_query`), a script's report (`scriptReport`) and the error from an internal fault included, for the - libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` + (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal + fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it, so that the From f6cabe62cc0e2435ad9d7b15dd81f26745ca96b0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 17:31:25 -0800 Subject: [PATCH 15/35] MCP: a readable library id written with LOADED/ keeps its text on Windows The loaded/ prefix is matched in any case where the ids and paths are. --- crates/ui-egui/src/control.rs | 3 ++- crates/ui-egui/src/control/tests.rs | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ee24d563bf..abe602b6d7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -694,8 +694,9 @@ fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { let rest = &text[at..]; let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); + let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { - let id = if rest.starts_with("loaded/") { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) }) } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 026b51bed6..621fa6ab50 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -105,6 +105,11 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); + // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. + let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); + let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true }); + assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); assert_eq!(reply["error"], "file:///b.svg", "{reply}"); From 62be2df0585a251acd7278af5d9ddba422da4a6c Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:50:00 -0800 Subject: [PATCH 16/35] MCP: replies hide each sent path the same way whatever the outcome Each hidden id or path is replaced once, chosen from the reply as given, so a hidden path inside another library's shown id is not replaced again, and a readable id written with %XX escapes keeps the hidden path it begins with. A path quoted with {:?} is matched with its quotes and control characters escaped. A path outside the read roots that the request sent is shown by its file name in a reply that succeeded as in one that failed, and a path param counts with a relative path too. A relative path is not replaced where it ends a longer path. Without a read root, a file: URL that names another host stays in ui.inspect's recorded steps. --- crates/ui-egui/src/control.rs | 216 +++++++++++++------------ crates/ui-egui/src/control/tests.rs | 83 +++++++++- crates/ui-egui/src/tests/automation.rs | 5 +- docs/mcp.md | 8 +- 4 files changed, 200 insertions(+), 112 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index abe602b6d7..40ec47f7f5 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -401,13 +401,19 @@ impl Hiding { } /// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths -/// ([`unreadable_path_like`]), each as a hidden library id (`loaded/<path>`) with `loaded/<file name>` -/// shown. The error of a failed request hides them as it hides a hidden library's path, with that file -/// name also for a hidden library shown as `loaded/<file name> (2)`, so that it reads the same whether -/// or not a library was loaded from one of them. +/// ([`unreadable_path_like`]) or held by a path param +/// ([`vectorcraft_engine::file_access::is_path_key`]), a relative one too, each as a hidden library id +/// (`loaded/<path>`) with `loaded/<file name>` shown. A reply hides them as it hides a hidden library's +/// path, with that file name also for a hidden library shown as `loaded/<file name> (2)`, so that it +/// reads the same whether or not a library was loaded from one of them. fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { - fn add(s: &str, out: &mut Vec<(String, String)>) { - if !unreadable_path_like(s) { + collect_sent_paths(v, false, out); +} + +/// [`sent_paths`] on `v`, which a path param holds when `path_param` is set. +fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String)>) { + fn add(s: &str, path_param: bool, out: &mut Vec<(String, String)>) { + if !unreadable_path_like(s) && !(path_param && !readable_value(s)) { return; } // A URL that names another host as the path after its scheme (`//host/share/…`). @@ -423,12 +429,12 @@ fn sent_paths(v: &Value, out: &mut Vec<(String, String)>) { } } match v { - Value::String(s) => add(s, out), - Value::Array(items) => items.iter().for_each(|item| sent_paths(item, out)), + Value::String(s) => add(s, path_param, out), + Value::Array(items) => items.iter().for_each(|item| collect_sent_paths(item, path_param, out)), Value::Object(fields) => { for (key, item) in fields { - add(key, out); - sent_paths(item, out); + add(key, false, out); + collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out); } } _ => {} @@ -470,7 +476,7 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// ([`begins_known_id`]): a readable library's id that begins with a hidden one keeps its path. On /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { - let pairs = hidden_text(&hiding.hidden); + let pairs = hidden_text(hiding); if pairs.is_empty() { return; } @@ -478,16 +484,22 @@ fn hide_library_ids(v: &mut Value, hiding: &Hiding) { } /// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and with each `\` -/// doubled (a path quoted with `{:?}`). -fn hidden_text(ids: &[(String, String)]) -> Vec<(String, String)> { +/// path in it with the file name shown, the path written with either separator too, and as `{:?}` +/// quotes it (each `\`, quote and control character escaped). +fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { + let quoted = |s: &str| { + let q = format!("{s:?}"); + q[1..q.len() - 1].to_string() + }; let mut pairs: Vec<(String, String)> = Vec::new(); - for (id, shown) in ids { + for (id, shown) in &hiding.hidden { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); - let back = path.replace('/', "\\"); - for path in [path.to_string(), path.replace('\\', "/"), back.replace('\\', "\\\\"), back] { - for (from, to) in [(format!("loaded/{path}"), shown.clone()), (path, name.to_string())] { + let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); + let debug = [quoted(path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); + let plain = [path.to_string(), forward, back].map(|p| (p, name.to_string())); + for (path, name) in plain.into_iter().chain(debug) { + for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { if !pairs.iter().any(|(f, _)| *f == from) { pairs.push((from, to)); } @@ -556,46 +568,70 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence /// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written -/// with `%XX` escapes (in a `file:` URL) is -/// replaced too; the escapes around it stay. `None` when nothing changed. +/// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence +/// is found in `s` as it was given, longest `from` first, and one that overlaps an occurrence already +/// found, or a loaded library's id that one began, stays: a replacement's text is not searched again. +/// With the rewrite's `fold`, the ids and paths as written are found first, so that two libraries whose +/// paths differ only in letter case keep their own ids, and then the ones written in another case. +/// `None` when nothing changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { - let replaced = replace_plain_text(s, r); - let text = replaced.as_deref().unwrap_or(s); - if !text.contains('%') { - return replaced; - } - let Some((decoded, spans)) = decoded_with_spans(text) else { return replaced }; - if decoded == text { - return replaced; - } - // The spans of `text` to replace, found in the decoded text, longest `from` first. - let mut cuts: Vec<(usize, usize, &str)> = Vec::new(); - let mut taken: Vec<(usize, usize)> = Vec::new(); - for (from, to) in &r.pairs { - for at in occurrences(&decoded, from, r.fold) { - let end = at + from.len(); - let free = !taken.iter().any(|&(a, b)| at < b && a < end); - if free && !begins_known_id(&decoded, at, end, r) { - taken.push((at, end)); - cuts.push((spans[at].0, spans[end - 1].1, to.as_str())); + let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; + let mut found = Found::default(); + for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { + for (from, to) in &r.pairs { + if let Some((text, spans)) = &decoded { + found.claim(text, Some(spans.as_slice()), from, to, fold, r); } + found.claim(s, None, from, to, fold, r); } } - if cuts.is_empty() { - return replaced; + if found.cuts.is_empty() { + return None; } + let mut cuts = found.cuts; cuts.sort_unstable_by_key(|&(start, _, _)| start); - let mut out = String::with_capacity(text.len()); + let mut out = String::with_capacity(s.len()); let mut copied = 0; for (start, stop, to) in cuts { - out.push_str(&text[copied..start]); + out.push_str(&s[copied..start]); out.push_str(to); copied = stop; } - out.push_str(&text[copied..]); + out.push_str(&s[copied..]); Some(out) } +/// The spans of a text that [`replace_text`] replaces, each with its replacement, and every span it has +/// taken: the ones it replaces and the loaded library ids it keeps. +#[derive(Default)] +struct Found<'a> { + cuts: Vec<(usize, usize, &'a str)>, + taken: Vec<(usize, usize)>, +} + +impl<'a> Found<'a> { + /// Takes each occurrence of `from` in `text` that overlaps no span taken: as a span to write as `to`, + /// or, where it begins a loaded library's id ([`begins_known_id`]), that id's span, kept. `spans` + /// gives the range of the original text each byte of a decoded `text` was read from. + fn claim(&mut self, text: &str, spans: Option<&[(usize, usize)]>, from: &str, to: &'a str, fold: bool, r: &Rewrite) { + let span = |at: usize, end: usize| spans.map_or((at, end), |s| (s[at].0, s[end - 1].1)); + for at in occurrences(text, from, fold) { + let end = at + from.len(); + let (start, stop) = span(at, end); + if self.taken.iter().any(|&(a, b)| start < b && a < stop) || ends_a_longer_path(text, at, from) { + continue; + } + match begins_known_id(text, at, end, r) { + Some(len) => self.taken.push(span(at, at + len)), + None => { + self.taken.push((start, stop)); + self.cuts.push((start, stop, to)); + } + } + } + } +} + /// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` /// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { @@ -639,44 +675,18 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { Some((decoded, decoded_spans)) } -/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, as [`replace_text`] -/// writes them in text without `%XX` escapes. With the rewrite's `fold`, the ids and paths as written -/// are replaced first, so that two libraries whose paths differ only in letter case keep their own -/// ids, and then the ones written in another case. `None` when nothing changed. -fn replace_plain_text(s: &str, r: &Rewrite) -> Option<String> { - let exact = replace_matches(s, r, false); - if !r.fold { - return exact; - } - replace_matches(exact.as_deref().unwrap_or(s), r, true).or(exact) -} - -/// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, matched in any case -/// of ASCII letters with `fold`. `None` when nothing changed. -fn replace_matches(s: &str, r: &Rewrite, fold: bool) -> Option<String> { - let mut out: Option<String> = None; - for (from, to) in &r.pairs { - let text = out.as_deref().unwrap_or(s); - let found = occurrences(text, from, fold); - if found.is_empty() { - continue; - } - let mut next = String::with_capacity(text.len()); - let mut copied = 0; - for at in found { - let end = at + from.len(); - if !begins_known_id(text, at, end, r) { - next.push_str(&text[copied..at]); - next.push_str(to); - copied = end; - } - } - if copied > 0 { - next.push_str(&text[copied..]); - out = Some(next); - } - } - out +/// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at +/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a +/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path +/// `data/icon.svg`; `./data/icon.svg` does. +fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { + let absolute = + from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + let before = &text[..at]; + !absolute + && !before.ends_with("./") + && !before.ends_with(".\\") + && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) } /// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, @@ -687,17 +697,18 @@ fn continues_path(rest: &str) -> bool { /// Does the occurrence `text[at..end]` begin a longer loaded library id ([`Hiding::readable`]), written /// with or without its `loaded/` and with no `.` or `..` part, that ends where the text stops -/// continuing a path ([`continues_path`])? +/// continuing a path ([`continues_path`])? The length of that id as written, when it does. /// A readable library's id that begins with a hidden one stays, also with a space after the hidden /// part; a hidden path followed by other path text (`<path>.bak`, `<path>/../x`) is still hidden. -fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> bool { +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option<usize> { let rest = &text[at..]; let same = |a: &str, b: &str| if r.fold { a.eq_ignore_ascii_case(b) } else { a == b }; let dotted = |id: &str| id.split(['/', '\\']).any(|part| part == "." || part == ".."); let prefixed = rest.get(.."loaded/".len()).is_some_and(|head| same(head, "loaded/")); - r.hiding.readable.iter().filter(|known| !dotted(known)).any(|known| { + r.hiding.readable.iter().filter(|known| !dotted(known)).find_map(|known| { let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; - id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]) + let begins = id.len() > end - at && rest.get(..id.len()).is_some_and(|head| same(head, id)) && !continues_path(&rest[id.len()..]); + begins.then_some(id.len()) }) } @@ -786,11 +797,11 @@ fn readable(path: &str) -> bool { } /// Is `s` a path the request may not read, written as an absolute path (either separator, with or -/// without a drive) or a `file:` URL ([`file_url_path`])? A `file:` URL that names another host is -/// never readable. +/// without a drive) or a `file:` URL ([`file_url_path`])? Under a read root, a `file:` URL that names +/// another host is never readable. fn unreadable_absolute(s: &str) -> bool { match file_url_path(s) { - Some(path) => !path.is_some_and(|p| readable(&p)), + Some(path) => !readable_url_path(path), None => { let absolute = s.starts_with(['/', '\\']) || matches!(s.as_bytes(), [drive, b':', b'/' | b'\\', ..] if drive.is_ascii_alphabetic()); absolute && !readable(s) @@ -814,11 +825,20 @@ fn unreadable_path_like(s: &str) -> bool { /// May the request read the path `s` names, a path or a `file:` URL ([`file_url_path`])? fn readable_value(s: &str) -> bool { match file_url_path(s) { - Some(path) => path.is_some_and(|p| readable(&p)), + Some(path) => readable_url_path(path), None => readable(s), } } +/// May the request read the local path a `file:` URL names ([`file_url_path`])? A URL that names +/// another host may be read only without a read root in force. +fn readable_url_path(path: Option<String>) -> bool { + match path { + Some(path) => readable(&path), + None => vectorcraft_engine::file_access::current().is_none(), + } +} + /// The local path that `s` names when it is a `file:` URL, with the scheme in any case and its `%XX` /// escapes decoded: `Some(None)` for a URL that names another host (`file://host/share`). /// `file:///C:/x` names `C:/x`. @@ -1001,8 +1021,8 @@ const DOCKING_ACTION: &str = "action"; /// in a [`LIBRARY_PARAMS`] param, and the reply shows that id. Under a read root, the reply shows each /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The -/// error of a failed request also hides the paths outside the read root that the request sent -/// ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// reply also shows each path outside the read root that the request sent by its file name, whether it +/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path /// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent /// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id /// or path. @@ -1037,19 +1057,17 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Outcome::Done(reply) } -/// Hides in `reply` the libraries hidden now and in `before`, and for a failed request the paths -/// outside the read root that `sent`, the request's params, holds ([`handle`]). +/// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that +/// `sent`, the request's params, holds ([`handle`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { if !holds_a_separator(reply) { return; } let mut hiding = unreadable_library_ids(app); hiding.merge(before); - if reply.get("ok").and_then(Value::as_bool) == Some(false) { - let mut paths = Vec::new(); - sent_paths(sent, &mut paths); - hiding.set_hidden(paths, cfg!(windows)); - } + let mut paths = Vec::new(); + sent_paths(sent, &mut paths); + hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 621fa6ab50..3d2a68b2be 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -46,7 +46,7 @@ fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { #[test] fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); - let data = "QUJDassets/iconREVGxQQ=="; + let data = "QUJ+assets/iconREVGxQQ=="; let mut reply = json!({"ok": true, "result": { "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], "status": "from assets/icon", @@ -61,7 +61,7 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { assert_eq!(reply["result"]["status"], "from icon", "{reply}"); assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); - assert_eq!(reply["result"]["otherBase64"], "QUJDiconREVGxQQ==", "{reply}"); + assert_eq!(reply["result"]["otherBase64"], "QUJ+iconREVGxQQ==", "{reply}"); } /// A hidden path followed by more text stays only when the longer text is another loaded library's @@ -96,19 +96,19 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { fn a_reply_hides_a_path_in_any_case_and_escaped() { let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); let mut reply = json!({"error": "from c:/LIBS/my swatches/B.SVG and file:///C:/Libs/My%20Swatches/b.svg"}); - let pairs = super::hidden_text(&hidden.hidden); + let pairs = super::hidden_text(&hidden); let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; super::replace_in_value(&mut reply, &rewrite); assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); // Two libraries whose paths differ only in letter case keep their own ids. let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two.hidden), hiding: &two, fold: true }); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&two), hiding: &two, fold: true }); assert_eq!(reply["error"], "x B.svg y b.svg z b.svg", "{reply}"); // A readable id that begins with a hidden one stays whole in any case, `loaded/` included. let nested = hiding(&[("loaded//libs/in", "loaded/in")], &["loaded//libs/in/a.svg"]); let mut reply = json!({"error": "LOADED//LIBS/IN/A.SVG"}); - super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested.hidden), hiding: &nested, fold: true }); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&nested), hiding: &nested, fold: true }); assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); @@ -132,8 +132,8 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { } /// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names -/// another host), under `~` or starting with `../`, are hidden by their file names; plain text and a -/// path inside the root are not. +/// another host), under `~` or starting with `../`, or as a relative path in a path param, are hidden +/// by their file names; plain text and a path inside the root are not. #[test] fn a_request_sends_paths_that_an_error_hides() { let base = std::env::temp_dir().join(format!("vc-sent-paths-{}", std::process::id())); @@ -141,13 +141,14 @@ fn a_request_sends_paths_that_an_error_hides() { std::fs::create_dir_all(&root).unwrap(); let inside = root.join("a.svg").to_string_lossy().into_owned(); let keep = json!(["a/b", inside, "file://host/g.svg"]); - let params = json!({"path": "/x/my lib/b.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); + let params = json!({"path": "/x/my lib/b.svg", "file": "outside/h.svg", "list": ["file:///y/c%20d.svg", "~/e.svg", "../f.svg"], "other": keep}); let mut sent = Vec::new(); let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(¶ms, &mut sent)); let _ = std::fs::remove_dir_all(base); let pair = |path: &str, name: &str| (format!("loaded/{path}"), format!("loaded/{name}")); let want = [ + pair("outside/h.svg", "h.svg"), pair("/y/c d.svg", "c d.svg"), pair("~/e.svg", "e.svg"), pair("../f.svg", "f.svg"), @@ -183,6 +184,72 @@ fn an_absolute_path_outside_the_read_root_is_unreadable() { let _ = std::fs::remove_dir_all(base); } +/// Without a read root, a `file:` URL that names another host may be read, and a recorded step keeps +/// it. +#[test] +fn a_url_naming_another_host_stays_without_a_read_root() { + assert!(!super::unreadable_absolute("file://host/share/a.svg")); + assert!(super::readable_value("file://host/share/a.svg")); + let mut params = json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]}); + let fields = params.as_object_mut().unwrap(); + super::leave_out_unreadable_params(fields); + assert_eq!(params, json!({"path": "file://host/share/a.svg", "list": ["file://host/share/b.svg"]})); +} + +/// A hidden path quoted with `{:?}` is hidden with its quotes and control characters escaped. +#[test] +fn a_reply_hides_a_quoted_path_with_escaped_quotes() { + let hidden = hiding(&[("loaded//outside/a\"b\t.svg", "loaded/a\"b\t.svg")], &[]); + let mut reply = json!({"error": format!("can't read {:?}", "/outside/a\"b\t.svg")}); + hide_under_a_root("quoted", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read \"a\\\"b\\t.svg\"", "{reply}"); +} + +/// Each replacement is chosen from the text as given: a hidden path inside the id shown for another +/// hidden library is not replaced again, and a readable id written with `%XX` escapes keeps the hidden +/// path it begins with. +#[test] +fn a_reply_replaces_each_hidden_text_once() { + let hidden = hiding(&[("loaded//b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "no loaded//b.svg"}); + hide_under_a_root("root-file", &mut reply, &hidden); + assert_eq!(reply["error"], "no loaded/b.svg", "{reply}"); + let hidden = hiding(&[("loaded//art/in", "loaded/in")], &["loaded//art/in work/a.svg"]); + let mut reply = json!({"error": "file:///art/in%20work/a.svg"}); + hide_under_a_root("escaped-readable", &mut reply, &hidden); + assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); +} + +/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a +/// longer path. +#[test] +fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { + let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); + let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); + hide_under_a_root("relative", &mut reply, &hidden); + assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); +} + +/// A path outside the read root that a request sent is shown by its file name in a reply that succeeded +/// as in one that failed. +#[test] +fn a_sent_path_reads_the_same_whatever_the_outcome() { + let app = VectorcraftApp::new(Session::new(), Services::default()); + let root = std::env::temp_dir().join(format!("vc-sent-outcome-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let sent = json!({"path": "/outside/b.svg"}); + let mut done = json!({"ok": true, "result": {"path": "/outside/b.svg"}}); + let mut failed = json!({"ok": false, "error": "can't read /outside/b.svg"}); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + super::hide_in_reply(&app, &mut done, &sent, super::Hiding::default()); + super::hide_in_reply(&app, &mut failed, &sent, super::Hiding::default()); + }); + let _ = std::fs::remove_dir_all(root); + assert_eq!(done["result"]["path"], "b.svg", "{done}"); + assert_eq!(failed["error"], "can't read b.svg", "{failed}"); +} + #[test] fn deferred_pointer_reply_uses_the_gesture_document_after_file_new() { let mut app = VectorcraftApp::new(Session::new(), Services::default()); diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 68717c3f8d..57d63e9f69 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -729,7 +729,8 @@ fn a_panic_reply_hides_a_library_hidden_before_the_request() { /// An error shows a path outside the read root that the request sent by its file name, whether or not /// a hidden library was loaded from that path, so that it reads the same either way. A path the -/// request sends does not show the app's own text that holds it, in an error or a result. +/// request sends does not show the app's own text that holds it, in an error or a result, and a result +/// shows that path by its file name as an error does. #[test] fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let (mut app, tx, base) = confined("client-echo"); @@ -746,7 +747,7 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { assert!(r["error"].as_str().is_some_and(|e| e.ends_with(&format!(": {name}"))), "{r}"); } let r = request(&mut app, &tx, "test.status", json!({"guess": path})); - assert_eq!(r["result"], "Loaded b.svg (2)", "{r}"); + assert_eq!(r["result"], "Loaded b.svg", "{r}"); let r = request(&mut app, &tx, "test.panic", json!({"guess": path})); assert_eq!(r["error"], "internal error: failed on Loaded b.svg (please report this bug)", "{r}"); let _ = std::fs::remove_dir_all(base); diff --git a/docs/mcp.md b/docs/mcp.md index b4cfad7e5f..a3c47ec71d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -169,9 +169,11 @@ the suite) keep the agent to the folders you give it: (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins - another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. The error of a failed request also shows a path outside the - read roots that the request sent by its file name, whether or not a library was loaded from it, so that the - error reads the same either way. Encoded file data, a base64 string under `dataBase64`, + another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the + read roots that the request sent by its file name, whether or not a library was loaded from it and whether the + request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, + `destinationFolder` and the like) counts with a relative path too. A relative path is not replaced where it ends a + longer path (`/work/data/icon.svg` keeps `data/icon.svg`). Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` From d1e766173e7216346b3dbcd3935df55d4c7c13a4 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 18:59:31 -0800 Subject: [PATCH 17/35] MCP: a library saved with another platform's absolute path is hidden under a read root Outside Windows, a library path that starts with a drive or a backslash counts as outside the read roots, since the file access check would read it as a name in the working folder. --- crates/engine/src/cmd/swatchlib.rs | 19 ++++++-- crates/ui-egui/src/control/tests.rs | 12 +++++ crates/ui-egui/src/tests/automation.rs | 64 +++++++++++++------------- docs/mcp.md | 3 +- 4 files changed, 62 insertions(+), 36 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 45320066cf..bc48f9d323 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -475,9 +475,18 @@ fn readable(path: &str) -> bool { crate::file_access::check_read(path).is_ok() } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? The file access check would read it as a name in the +/// working folder. +fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && crate::file_access::current().is_some() +} + /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is -/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then +/// named. The file name is the last part of the path after either separator, and a path saved as +/// another platform's absolute path counts as one the request may not read. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then /// `others` (ids the UI state refers to). When another id already is or shows that id, the next free /// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The /// session's libraries are numbered first, in the sorted order of their full ids, so a library shows @@ -493,9 +502,13 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin let mut taken = BTreeSet::new(); for ids in [session, others] { for id in ids { - let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path)); + let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| { + // Either separator: a path saved on another platform counts as a path here too. + let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); + (name, path) + }); match name { - Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), + Some((name, path)) if name != path && (written_elsewhere(path) || !readable(path)) => hidden.push((id, name)), _ => { taken.insert(id); } diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 3d2a68b2be..13890c28bb 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -220,6 +220,18 @@ fn a_reply_replaces_each_hidden_text_once() { assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); } +/// A library saved with another platform's absolute path is hidden, also when the working folder is +/// inside the read root. +#[test] +fn a_library_saved_with_another_platforms_path_is_hidden() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.ui.color_guide_limit = r"loaded/C:\Users\someone\b.svg".to_string(); + let here = std::env::current_dir().unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); + let hiding = vectorcraft_engine::file_access::confine(roots.as_ref(), || super::unreadable_library_ids(&app)); + assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}"); +} + /// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a /// longer path. #[test] diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 57d63e9f69..e36388bf31 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -446,33 +446,6 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } -/// Two libraries loaded from outside the read root under the same file name, only the second of them -/// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and -/// that id finds it. -#[test] -fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { - let (mut app, tx, base) = confined("one-id"); - let (x, y) = (base.join("outside/x/Kit.gpl"), base.join("outside/y/Kit.gpl")); - for (path, colors) in [(&x, "0 0 0 Black\n"), (&y, "0 0 0 Black\n255 255 255 White\n")] { - std::fs::create_dir_all(path.parent().unwrap()).unwrap(); - std::fs::write(path, format!("GIMP Palette\nName: Kit\n{colors}")).unwrap(); - app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); - } - let second = format!("loaded/{}", s(y.clone())); - app.ui.library_panels = vec![serde_json::from_value(json!({"kind": "swatches", "id": second, "path": s(y.clone())})).unwrap()]; - - let r = request(&mut app, &tx, "ui.inspect", json!({})); - assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/Kit.gpl (2)", "{r}"); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.list", "params": {}})); - let libs = r["result"]["libraries"].as_array().unwrap_or_else(|| panic!("{r}")); - let loaded: Vec<&Value> = libs.iter().filter(|l| l["category"] == "loaded").map(|l| &l["id"]).collect(); - assert_eq!(loaded, [&json!("loaded/Kit.gpl"), &json!("loaded/Kit.gpl (2)")], "{r}"); - let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.get", "params": {"library": "loaded/Kit.gpl (2)"}})); - assert_eq!(r["result"]["swatches"].as_array().map(Vec::len), Some(2), "the second library: {r}"); - assert!(!r.to_string().contains("outside"), "{r}"); - let _ = std::fs::remove_dir_all(base); -} - /// `ui.inspect` checks a folder preference as it is written: the read root's path with a space before /// it is a relative path, outside the root, and is left out. #[test] @@ -675,10 +648,10 @@ fn ui_inspect_keeps_a_hidden_library_off_a_loaded_librarys_id() { let _ = std::fs::remove_dir_all(base); } -/// Hidden libraries with the same file name are numbered in the order they were loaded, not in the -/// order of their paths. +/// Hidden libraries with the same file name that are shown for the first time in the same reply are +/// numbered in the order of their paths, as the library commands number them. #[test] -fn hidden_libraries_are_numbered_in_load_order() { +fn hidden_libraries_are_numbered_in_the_order_of_their_paths() { let (mut app, tx, base) = confined("hidden-load-order"); let gpl = "GIMP Palette\nName: p\n0 0 0 Black\n"; for dir in ["z", "a"] { @@ -687,7 +660,7 @@ fn hidden_libraries_are_numbered_in_load_order() { std::fs::write(&path, gpl).unwrap(); app.session.execute("swatch.library.load", &json!({"path": s(path)})).unwrap(); } - app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("a").join("b.gpl"))); + app.ui.color_guide_limit = format!("loaded/{}", s(base.join("outside").join("z").join("b.gpl"))); let r = request(&mut app, &tx, "ui.inspect", json!({})); assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.gpl (2)", "{r}"); let _ = std::fs::remove_dir_all(base); @@ -736,7 +709,7 @@ fn an_error_hides_a_sent_path_whether_or_not_a_library_was_loaded_from_it() { let (mut app, tx, base) = confined("client-echo"); let path = s(base.join("outside/b.svg")); // Another hidden library with the same file name comes first, so this one shows as `b.svg (2)`. - let first = format!("loaded/{}", s(base.join("outside/z/b.svg"))); + let first = format!("loaded/{}", s(base.join("outside/a/b.svg"))); let panel: crate::panels::library_panel::LibraryPanel = serde_json::from_value(json!({"kind": "swatches", "id": first})).unwrap(); app.ui.library_panels = vec![panel]; app.ui.color_guide_limit = format!("loaded/{path}"); @@ -808,6 +781,33 @@ fn ui_inspect_leaves_out_relative_paths_in_recorded_steps() { let _ = std::fs::remove_dir_all(base); } +/// Two libraries loaded from outside the read root under the same file name, only the second of them +/// in a library panel: `ui.inspect` and `swatch.library.list` show the second under the same id, and +/// that id finds it. +#[test] +fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { + let (mut app, tx, base) = confined("one-id"); + let (x, y) = (base.join("outside/x/Kit.gpl"), base.join("outside/y/Kit.gpl")); + for (path, colors) in [(&x, "0 0 0 Black\n"), (&y, "0 0 0 Black\n255 255 255 White\n")] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, format!("GIMP Palette\nName: Kit\n{colors}")).unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + } + let second = format!("loaded/{}", s(y.clone())); + app.ui.library_panels = vec![serde_json::from_value(json!({"kind": "swatches", "id": second, "path": s(y.clone())})).unwrap()]; + + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["library_panels"][0]["id"], "loaded/Kit.gpl (2)", "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.list", "params": {}})); + let libs = r["result"]["libraries"].as_array().unwrap_or_else(|| panic!("{r}")); + let loaded: Vec<&Value> = libs.iter().filter(|l| l["category"] == "loaded").map(|l| &l["id"]).collect(); + assert_eq!(loaded, [&json!("loaded/Kit.gpl"), &json!("loaded/Kit.gpl (2)")], "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "swatch.library.get", "params": {"library": "loaded/Kit.gpl (2)"}})); + assert_eq!(r["result"]["swatches"].as_array().map(Vec::len), Some(2), "the second library: {r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() diff --git a/docs/mcp.md b/docs/mcp.md index a3c47ec71d..1881416381 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -155,7 +155,8 @@ the suite) keep the agent to the folders you give it: roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with - either separator counts as a path, also one saved on another platform. When another hidden library has the same + either separator counts as a path, also one saved on another platform, and outside Windows a path that starts + with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of their full ids (the libraries loaded in this session first, then those only the UI state names), and each one keeps its id in both replies and in the library commands below. A hidden library skips an id that a library From d1f7888ad4eca556c6cee588b88696227625b19a Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:16:17 -0800 Subject: [PATCH 18/35] MCP: a hidden library keeps the id it was first shown under A library loaded from outside a confined client's read roots keeps its `loaded/<file name>` id for the session, in ui.inspect, ui.docking and the library commands, so an id taken from an earlier reply still finds the same library after another library with the same file name is loaded. swatch.library.copyToUser leaves out `path` when the file is outside the read roots, as the saves do. The ids come from the engine's concealed_library_ids, which now reads a path written with either separator, counts another platform's absolute path as outside the roots, and keeps each id in the session. --- crates/engine/src/cmd/swatchlib.rs | 60 +++++++++++++++++------- crates/engine/src/lib.rs | 3 ++ crates/engine/src/session/documents.rs | 1 + crates/engine/src/tests/library_roots.rs | 37 +++++++++++++-- crates/ui-egui/src/tests/automation.rs | 37 +++++++++++++++ docs/mcp.md | 22 +++++---- docs/target-app-parity.md | 2 +- 7 files changed, 131 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index bc48f9d323..7934de2c0f 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Copy to User Defined", ["Window", "Swatch Libraries"], None, - "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}", + "{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}", has_user_folder, copy_to_user ), @@ -368,7 +368,9 @@ impl<L: LibraryFile> Libraries<L> { .ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?; let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) { Some(p) => { - // Outside the request's read roots: the error names no path. + // The file's bytes are read: outside the request's read roots, the error names no + // path. A document's swatches (the arm below) are written from the loaded library, + // and the document is not read again. if !readable(&p) { return Err(bad(cmd, "the library's file is outside the read roots")); } @@ -486,11 +488,15 @@ fn written_elsewhere(path: &str) -> bool { /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is /// named. The file name is the last part of the path after either separator, and a path saved as -/// another platform's absolute path counts as one the request may not read. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then -/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free -/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The -/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows -/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none. +/// another platform's absolute path counts as one the request may not read. The ids are those of the +/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers +/// to). When another id already is or shows that id, the next free +/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library +/// keeps the id it was first shown under for the rest of the session, so an id a client was given +/// finds the same library after another one with the same file name is loaded; only a library that +/// shows that id itself (one loaded later without a file under that name) moves it to the next free +/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of +/// their full ids, then `others`, in the same order. Without roots in force, none. pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> { if crate::file_access::current().is_none() { return vec![]; @@ -515,16 +521,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin } } } + let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner()); + let mut kept = vec![None; hidden.len()]; + for (slot, (id, _)) in kept.iter_mut().zip(&hidden) { + if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) { + taken.insert(shown.clone()); + *slot = Some(shown.clone()); + } + } + // A new id is never one given to another library earlier in the session. + let mut given: BTreeSet<String> = assigned.values().cloned().collect(); hidden .into_iter() - .map(|(id, name)| { - let mut shown = format!("loaded/{name}"); - let mut n = 2; - while taken.contains(&shown) { - shown = format!("loaded/{name} ({n})"); - n += 1; - } - taken.insert(shown.clone()); + .zip(kept) + .map(|((id, name), kept)| { + let shown = kept.unwrap_or_else(|| { + let mut shown = format!("loaded/{name}"); + let mut n = 2; + while taken.contains(&shown) || given.contains(&shown) { + shown = format!("loaded/{name} ({n})"); + n += 1; + } + taken.insert(shown.clone()); + given.insert(shown.clone()); + assigned.insert(id.clone(), shown.clone()); + shown + }); (id, shown) }) .collect() @@ -759,8 +781,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> { "user" => (info, lib, false), _ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))), }; - let path = s.swatch_libraries.shown_path(&info.id); - Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied})) + let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied}); + // As the saves with `user: true`: no `path` when the request may not read the file. + if let Some(path) = s.swatch_libraries.shown_path(&info.id) { + out["path"] = json!(path); + } + Ok(out) } /// The library swatches `names` stand for, each once, with the colour group it goes into (`None`: diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9890c9b08c..c8abeb094c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -151,6 +151,9 @@ pub struct Session { pub symbol_libraries: cmd::symbollib::Libraries, /// User Defined and loaded brush libraries (Window → Brush Libraries); not saved. pub brush_libraries: cmd::brushlib::Libraries, + /// The id each loaded library outside a request's read roots has been shown under, by its own id + /// ([`cmd::swatchlib::concealed_library_ids`]), kept for the session. + shown_library_ids: std::sync::Mutex<std::collections::BTreeMap<String, String>>, /// The Libraries panel's libraries of graphics, colours and text styles (`library.*`). pub libraries: cmd::library::Libraries, /// Where the saved PDF presets are kept, one file each; `None`: only for the session diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 887c740936..c5ba0410bf 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -80,6 +80,7 @@ impl Session { style_libraries: Default::default(), symbol_libraries: Default::default(), brush_libraries: Default::default(), + shown_library_ids: Default::default(), libraries: Default::default(), pdf_preset_dir: None, dictionary_file: None, diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 30a8776b35..000f0e7532 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -214,26 +214,53 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let mine = format!("loaded/{}", text(&inside)); let copied = confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": mine}))); assert_eq!((copied["library"].as_str(), copied["copied"].as_bool()), (Some("user/Kit.vcswatches"), Some(true)), "{copied}"); - assert_eq!(copied["path"], Value::Null, "{copied}"); + assert_eq!(copied.get("path"), None, "{copied}"); let saved = confine(roots.as_ref(), || run(&mut s, "swatch.library.save", json!({"user": true, "name": "Mine"}))); assert_eq!((saved["library"].as_str(), saved.get("path")), (Some("user/Mine.vcswatches"), None), "{saved}"); for reply in [&added, &copied, &saved] { assert!(!holds(reply, &base.join("a")) && !holds(reply, &base.join("user")), "{reply}"); } - // A library loaded from data under the file name keeps its id; each concealed one takes the next - // free one, so no two rows share an id. + // A library loaded from data under the file name keeps its id; the concealed one shown under that + // id takes the next free one, the other keeps its id, and no two rows share an id. let data = run(&mut s, "swatch.library.save", json!({}))["data"].as_str().unwrap().to_string(); run(&mut s, "swatch.library.load", json!({"data": data, "name": "Kit.vcswatches"})); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches")], "{list}"); // Copied into the user library folder, it is still found by that id, which no other library takes. confine(roots.as_ref(), || run(&mut s, "swatch.library.copyToUser", json!({"library": "loaded/Kit.vcswatches"}))); let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); - assert_eq!(ids, [json!("loaded/Kit.vcswatches (2)"), json!("loaded/Kit.vcswatches (3)")], "{list}"); + assert_eq!(ids, [json!("loaded/Kit.vcswatches (3)"), json!("loaded/Kit.vcswatches (2)")], "{list}"); // The person sees the folder. let saved = run(&mut s, "swatch.library.save", json!({"user": true, "name": "Theirs"})); assert_eq!(saved["path"], json!(text(&base.join("user").join("Theirs.vcswatches")))); let _ = std::fs::remove_dir_all(base); } + +/// A concealed library keeps the id a client was shown when another library with the same file name, +/// whose full id sorts before it, is loaded later: the id still finds the first library. +#[test] +fn a_concealed_library_keeps_its_id_after_another_with_its_file_name_loads() { + let base = folder("keeps-id"); + let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap(); + let (first, later) = (base.join("b").join("Kit.vcswatches"), base.join("a").join("Kit.vcswatches")); + let mut s = Session::new(); + run(&mut s, "file.new", json!({"width": 100, "height": 100})); + (SWATCHES.add)(&mut s, "First"); + run(&mut s, "swatch.library.save", json!({"path": text(&first), "name": "Kit"})); + (SWATCHES.add)(&mut s, "Second"); + run(&mut s, "swatch.library.save", json!({"path": text(&later), "name": "Kit"})); + let shown = "loaded/Kit.vcswatches"; + let count = run(&mut s, "swatch.library.load", json!({"path": text(&first)}))["count"].clone(); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + assert_eq!(loaded(&list), [(json!(shown), Value::Null, count)], "{list}"); + run(&mut s, "swatch.library.load", json!({"path": text(&later)})); + let list = confine(roots.as_ref(), || run(&mut s, "swatch.library.list", json!({}))); + let ids: Vec<Value> = loaded(&list).into_iter().map(|(id, ..)| id).collect(); + assert_eq!(ids, [json!(shown), json!("loaded/Kit.vcswatches (2)")], "{list}"); + let theirs = run(&mut s, "swatch.library.get", json!({"library": format!("loaded/{}", text(&first))})); + let got = confine(roots.as_ref(), || run(&mut s, "swatch.library.get", json!({"library": shown}))); + assert_eq!((&got["id"], &got["swatches"]), (&json!(shown), &theirs["swatches"]), "the first library: {got}"); + let _ = std::fs::remove_dir_all(base); +} diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index e36388bf31..aef826c538 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -808,6 +808,43 @@ fn ui_inspect_and_library_commands_show_a_hidden_library_under_one_id() { let _ = std::fs::remove_dir_all(base); } +/// A library panel command given a hidden library's shown id opens the library and answers with +/// that id, not the library's path. +#[test] +fn a_library_panel_command_answers_with_a_hidden_librarys_shown_id() { + let (mut app, tx, base) = confined("panel-shown-id"); + let path = base.join("outside/x/Kit.gpl"); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "GIMP Palette\nName: Kit\n0 0 0 Black\n").unwrap(); + app.session.execute("swatch.library.load", &json!({"path": s(path.clone())})).unwrap(); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.swatchLibrary", "params": {"library": "loaded/Kit.gpl"}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(r.to_string().contains("loaded/Kit.gpl") && !r.to_string().contains("outside"), "{r}"); + assert_eq!(app.ui.library_panels.last().map(|p| p.id.clone()), Some(format!("loaded/{}", s(path))), "the app's own id"); + // The panel id it answers with names the panel in the panel commands. + let panel = r["result"]["panel"].clone(); + assert_eq!(panel, "library:swatches:loaded/Kit.gpl", "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!((&r["ok"], &r["result"]["panel"]), (&json!(true), &panel), "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "window.panel.move", "params": {"panel": "swatches", "anchor": panel, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "window.panel.close", "params": {"panel": panel}})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + // Closed, the panel isn't found, and the error names it by the id given. + let r = request(&mut app, &tx, "engine.execute", json!({"command": "ui.libraryPanel", "params": {"panel": panel, "view": "largeList"}})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!r.to_string().contains("outside"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A closed panel's former place, as `ui.docking_hidden` keeps it. fn hidden_place() -> craft_ui::docking::Location<String> { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() diff --git a/docs/mcp.md b/docs/mcp.md index 1881416381..9808d514c5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -157,11 +157,13 @@ the suite) keep the agent to the folders you give it: params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same - file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order of - their full ids (the libraries loaded in this session first, then those only the UI state names), and each one - keeps its id in both replies and in the library commands below. A hidden library skips an id that a library - loaded without a path already has. The other entries keep their order, and the app's own state is not - changed. A request may name such a library or its panel by the id + file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they + are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the + libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest + of the session in both replies and in the library commands below, also after another library with the same + file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a + library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next + free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path @@ -182,9 +184,13 @@ the suite) keep the agent to the folders you give it: `(2)`, `(3)` and so on as in `ui_inspect` when two have the same file name. That id finds the library in the other library commands (`swatch.library.get`, `swatch.library.add`, `brush.library`, `brush.addFromLibrary`, `symbol.library`, `symbol.addFromLibrary`, `graphicStyle.library`, `graphicStyle.addFromLibrary` and the - others that take `library`), whose replies show it too. `swatch.library.copyToUser` refuses a library whose file - is outside the read roots without naming the file, and it and the library saves with `user: true` leave out - `path` when the user library folder is outside the read roots. + others that take `library`), whose replies show it too. Over the control channel, `window.swatchLibrary` and + the other library panel commands answer with that id and its panel's id (`library:swatches:loaded/<file name>`), + and `ui.libraryPanel` and the `window.panel.*` commands take that panel id in `panel`, `anchor`, `before`, + `onto`, `above` and `below`. `swatch.library.copyToUser` refuses to copy a library file outside the read roots + without naming the file (the swatches of a document loaded from there are written from the loaded library, as + for a library loaded from data, and the document is not read again), and it and the library saves with + `user: true` leave out `path` when the user library folder is outside the read roots. - `links.list`, `links.check`, `links.info` and the `missingLinks`, `modifiedLinks` and `updatedLinks` rows of `document.open` leave out a linked file's path when the file is outside the read roots (a relative path is taken from the document's folder): `path`, `found`, and in `links.info` also `link` and `location`, with the `fileSize` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d98bb46575..0c567096a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -769,7 +769,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: library files and the user library folder outside the read roots are left out, a library loaded from outside them is listed and found by its file name, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 6d7a8249a73d1b4257969f9c7052af0f98b41b3f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:44:43 -0800 Subject: [PATCH 19/35] MCP: hidden library paths are matched before the paths a request sent A sent path whose text overlapped part of a hidden library's path could keep that path from being hidden; the hidden libraries' ids and paths are now matched first. A hidden relative path is also hidden where a reply shows the absolute path it reads as from the working folder, and a hidden library sent by that other form shows as the sent path does. --- crates/ui-egui/src/control.rs | 97 +++++++++++++++++--------- crates/ui-egui/src/control/tests.rs | 41 +++++++++-- crates/ui-egui/src/tests/automation.rs | 2 +- docs/mcp.md | 6 +- 4 files changed, 106 insertions(+), 40 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 40ec47f7f5..65f7fae972 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -356,7 +356,7 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { .collect(); // The longest first, so that an id that begins another one does not replace part of it. hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - Hiding { hidden, readable: readable_ids } + Hiding { hidden, readable: readable_ids, sent: Vec::new() } } /// What a reply hides ([`hide_library_ids`]). @@ -368,6 +368,9 @@ struct Hiding { /// The other loaded library ids the UI state and the session hold: an occurrence of a hidden id or /// path that begins one of them stays ([`begins_known_id`]). readable: std::collections::BTreeSet<String>, + /// The paths outside the read roots that the request sent ([`sent_paths`]), each as a hidden + /// library id with the id shown. They are matched after the hidden libraries. + sent: Vec<(String, String)>, } impl Hiding { @@ -387,16 +390,26 @@ impl Hiding { self.hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); } - /// Adds the hidden ids of `pairs`, each with the id shown, in place of each id this one holds for - /// the same path: written with either separator, and in any case of ASCII letters with `fold`. + /// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds + /// for the same path (written with either separator, relative or as the absolute path it reads as + /// ([`absolute_form`]), and in any case of ASCII letters with `fold`) is shown as the sent path is. fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { let key = |id: &str| { - let id = id.replace('\\', "/"); + let path = id.strip_prefix("loaded/").unwrap_or(id); + let id = absolute_form(path).unwrap_or_else(|| path.to_string()).replace('\\', "/"); if fold { id.to_ascii_lowercase() } else { id } }; let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); - self.hidden.retain(|(id, _)| !sent.contains(&key(id))); - self.add_hidden(pairs); + for (id, shown) in &mut self.hidden { + if let Some(at) = sent.iter().position(|k| *k == key(id)) { + shown.clone_from(&pairs[at].1); + } + } + for pair in pairs { + if !self.sent.iter().any(|(id, _)| *id == pair.0) { + self.sent.push(pair); + } + } } } @@ -477,31 +490,48 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet<Str /// Windows the ids and paths are matched in any case of ASCII letters. fn hide_library_ids(v: &mut Value, hiding: &Hiding) { let pairs = hidden_text(hiding); - if pairs.is_empty() { + if pairs.iter().all(Vec::is_empty) { return; } replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); } -/// The text [`hide_library_ids`] replaces, longest first: each hidden id with the id shown, and the -/// path in it with the file name shown, the path written with either separator too, and as `{:?}` -/// quotes it (each `\`, quote and control character escaped). -fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { +/// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of +/// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with +/// the id shown, and the path in it with the file name shown, the path written with either separator +/// too, and as `{:?}` quotes it (each `\`, quote and control character escaped). A relative path is +/// also replaced as the absolute path the file access check reads it as, against the working folder. +fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> { + vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)] +} + +/// The absolute path that `path`, a relative path (one that does not start with a separator, a drive +/// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other +/// path. +fn absolute_form(path: &str) -> Option<String> { + let relative = !path.starts_with(['/', '\\', '~']) && !matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + relative.then(|| std::path::absolute(path).ok()).flatten().map(|p| p.to_string_lossy().into_owned()) +} + +/// The text [`hidden_text`] replaces for the ids of `ids`, longest first. +fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> { let quoted = |s: &str| { let q = format!("{s:?}"); q[1..q.len() - 1].to_string() }; let mut pairs: Vec<(String, String)> = Vec::new(); - for (id, shown) in &hiding.hidden { + for (id, shown) in ids { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); - let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); - let debug = [quoted(path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); - let plain = [path.to_string(), forward, back].map(|p| (p, name.to_string())); - for (path, name) in plain.into_iter().chain(debug) { - for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { - if !pairs.iter().any(|(f, _)| *f == from) { - pairs.push((from, to)); + for path in std::iter::once(path.to_string()).chain(absolute_form(path)) { + let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); + let debug = [quoted(&path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); + let plain = [path, forward, back].map(|p| (p, name.to_string())); + for (path, name) in plain.into_iter().chain(debug) { + for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { + if !pairs.iter().any(|(f, _)| *f == from) { + pairs.push((from, to)); + } } } } @@ -510,10 +540,10 @@ fn hidden_text(hiding: &Hiding) -> Vec<(String, String)> { pairs } -/// What [`replace_in_value`] writes: the text to replace, what the reply hides, and whether to match -/// in any case of ASCII letters. +/// What [`replace_in_value`] writes: the groups of text to replace ([`hidden_text`]), what the reply +/// hides, and whether to match in any case of ASCII letters. struct Rewrite<'a> { - pairs: Vec<(String, String)>, + pairs: Vec<Vec<(String, String)>>, hiding: &'a Hiding, fold: bool, } @@ -569,20 +599,23 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// `s` with the `from` texts of the rewrite's pairs written as their `to` texts, at each occurrence /// that does not begin another loaded library's id ([`begins_known_id`]). A hidden id or path written /// with `%XX` escapes (in a `file:` URL) is replaced too; the escapes around it stay. Every occurrence -/// is found in `s` as it was given, longest `from` first, and one that overlaps an occurrence already -/// found, or a loaded library's id that one began, stays: a replacement's text is not searched again. -/// With the rewrite's `fold`, the ids and paths as written are found first, so that two libraries whose -/// paths differ only in letter case keep their own ids, and then the ones written in another case. -/// `None` when nothing changed. +/// is found in `s` as it was given, the hidden libraries' text before the sent paths' text, each group +/// longest `from` first, and one that overlaps an occurrence already found, or a loaded library's id +/// that one began, stays: a replacement's text is not searched again. With the rewrite's `fold`, the +/// ids and paths of a group as written are found first, so that two libraries whose paths differ only +/// in letter case keep their own ids, and then the ones written in another case. `None` when nothing +/// changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; let mut found = Found::default(); - for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { - for (from, to) in &r.pairs { - if let Some((text, spans)) = &decoded { - found.claim(text, Some(spans.as_slice()), from, to, fold, r); + for group in &r.pairs { + for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { + for (from, to) in group { + if let Some((text, spans)) = &decoded { + found.claim(text, Some(spans.as_slice()), from, to, fold, r); + } + found.claim(s, None, from, to, fold, r); } - found.claim(s, None, from, to, fold, r); } } if found.cuts.is_empty() { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 13890c28bb..2723dc3da8 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -28,7 +28,7 @@ fn a_reply_hides_a_hidden_library_whatever_surrounds_it() { fn hiding(hidden: &[(&str, &str)], readable: &[&str]) -> super::Hiding { let mut hidden: Vec<(String, String)> = hidden.iter().map(|(id, shown)| (id.to_string(), shown.to_string())).collect(); hidden.sort_by_key(|(id, _)| std::cmp::Reverse(id.len())); - super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect() } + super::Hiding { hidden, readable: readable.iter().map(|id| id.to_string()).collect(), sent: Vec::new() } } /// [`super::hide_library_ids`] on `reply`, under a read root made for `tag`. @@ -125,10 +125,41 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { let mut hidden = hiding(&[(r"loaded/C:\Libs\b.svg", "loaded/b.svg (2)"), ("loaded//other/b.svg", "loaded/b.svg")], &[]); hidden.set_hidden(vec![("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())], true); - let ids: Vec<&str> = hidden.hidden.iter().map(|(id, _)| id.as_str()).collect(); - assert_eq!(ids, ["loaded/c:/libs/B.svg", "loaded//other/b.svg"]); - hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/b.svg".to_string())], false); - assert_eq!(hidden.hidden.len(), 3, "{hidden:?}"); + let shown: Vec<&str> = hidden.hidden.iter().map(|(_, shown)| shown.as_str()).collect(); + assert_eq!(shown, ["loaded/B.svg", "loaded/b.svg"], "{hidden:?}"); + assert_eq!(hidden.sent, [("loaded/c:/libs/B.svg".to_string(), "loaded/B.svg".to_string())]); + hidden.set_hidden(vec![("loaded//OTHER/b.svg".to_string(), "loaded/x.svg".to_string())], false); + assert_eq!((hidden.hidden[1].1.as_str(), hidden.sent.len()), ("loaded/b.svg", 2), "{hidden:?}"); + // A library loaded by a relative path, and the absolute path it reads as, sent: the sent path's + // file name is shown for both, not the library's numbered id. + let mut hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg (2)")], &[]); + let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); + hidden.set_hidden(vec![(format!("loaded/{absolute}"), "loaded/icon.svg".to_string())], false); + let mut reply = json!({"error": format!("can't read \"data/icon.svg\" or {absolute}")}); + hide_under_a_root("sent-absolute", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}"); +} + +/// A sent path whose text overlaps part of a hidden library's path does not keep that library's path +/// from being hidden: the hidden libraries are matched first. +#[test] +fn a_sent_path_does_not_stop_a_hidden_library_path_from_being_hidden() { + let mut hidden = hiding(&[("loaded//home/secret/dir/lib", "loaded/lib")], &[]); + hidden.set_hidden(vec![("loaded//dir/lib/sent-folder/longer".to_string(), "loaded/longer".to_string())], false); + let mut reply = json!({"error": "can't read /home/secret/dir/lib/sent-folder/longer"}); + hide_under_a_root("sent-overlap", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read lib/sent-folder/longer", "{reply}"); +} + +/// A hidden relative path is also hidden where a reply shows it as the absolute path it reads as from +/// the working folder. +#[test] +fn a_reply_hides_a_hidden_relative_path_written_as_absolute() { + let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); + let absolute = std::path::absolute("data/icon.svg").unwrap().to_string_lossy().into_owned(); + let mut reply = json!({"error": format!("can't read {absolute}")}); + hide_under_a_root("relative-absolute", &mut reply, &hidden); + assert_eq!(reply["error"], "can't read icon.svg", "{reply}"); } /// The paths outside the read root that a request sends, as a path, a `file:` URL (also one that names diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index aef826c538..28325fc08e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -905,7 +905,7 @@ fn ui_inspect_and_ui_docking_give_a_hidden_library_the_same_id() { let ui = &r["result"]["ui"]; assert_eq!( (ui["color_guide_limit"].as_str(), ui["open_panel"].as_str()), - (Some("loaded/b.svg (2)"), Some("library:swatches:loaded/b.svg")), + (Some("loaded/b.svg"), Some("library:swatches:loaded/b.svg (2)")), "{r}" ); let d = request(&mut app, &tx, "ui.docking", json!({})); diff --git a/docs/mcp.md b/docs/mcp.md index 9808d514c5..0d224f247b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -175,8 +175,10 @@ the suite) keep the agent to the folders you give it: another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it and whether the request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, - `destinationFolder` and the like) counts with a relative path too. A relative path is not replaced where it ends a - longer path (`/work/data/icon.svg` keeps `data/icon.svg`). Encoded file data, a base64 string under `dataBase64`, + `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are + matched before the paths the request sent, so a sent path written over part of a hidden library's path does not + keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` + keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, `pngBase64` or `thumbnailBase64`, is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` From 97b77fe4fdef211dd7010fa6963427a205db0208 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 19:59:09 -0800 Subject: [PATCH 20/35] MCP: another platform's absolute path is outside the read roots everywhere Outside Windows, a path that starts with a drive (`C:`) or a `\` is read as a name in the working folder, so with the working folder inside a read root it passed the read check. ui.inspect then kept it in recorded action steps and replies did not hide it when a request sent it. The check now counts such a path as outside the read roots wherever the control channel and the library commands check a path. --- crates/engine/src/cmd/swatchlib.rs | 15 ++++----------- crates/engine/src/file_access.rs | 7 +++++++ crates/ui-egui/src/control.rs | 5 +++-- crates/ui-egui/src/control/tests.rs | 15 +++++++++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 7934de2c0f..6f007c182d 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -472,17 +472,10 @@ fn file_name(path: &str) -> String { } /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without -/// roots in force. +/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is +/// never readable under a read root. fn readable(path: &str) -> bool { - crate::file_access::check_read(path).is_ok() -} - -/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one -/// that starts with a drive (`C:`) or a `\`? The file access check would read it as a name in the -/// working folder. -fn written_elsewhere(path: &str) -> bool { - let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - !cfg!(windows) && foreign && crate::file_access::current().is_some() + !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok() } /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each @@ -514,7 +507,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin (name, path) }); match name { - Some((name, path)) if name != path && (written_elsewhere(path) || !readable(path)) => hidden.push((id, name)), + Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), _ => { taken.insert(id); } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 9d05b17272..6ca724d810 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,13 @@ pub fn check_read(path: &str) -> Result<(), String> { current().map_or(Ok(()), |r| r.check(path, Access::Read)) } +/// Is `path`, under a read root, written as an absolute path of another platform: outside Windows, one +/// that starts with a drive (`C:`) or a `\`? [`check_read`] reads it as a name in the working folder. +pub fn written_elsewhere(path: &str) -> bool { + let foreign = path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + !cfg!(windows) && foreign && current().is_some() +} + /// Does a command param named `key` hold a file or folder path: `path`, `paths`, `file`, `files`, /// `folder`, or a name ending in `Path`, `Paths`, `File`, `Files` or `Folder` (`diffPath`, /// `destinationFolder`)? diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 65f7fae972..79d614b9d0 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -824,9 +824,10 @@ const SCRATCH_CHOICES: [&str; 2] = ["Startup", "None"]; /// May the request being served read `path`? Under an automation read root (a control client's /// request), `ui.inspect` leaves out a path outside the root, as `ui.menu.list` leaves out a recent -/// file or a listed script outside it. +/// file or a listed script outside it. Another platform's absolute path +/// ([`vectorcraft_engine::file_access::written_elsewhere`]) is never readable under a read root. fn readable(path: &str) -> bool { - vectorcraft_engine::file_access::check_read(path).is_ok() + !vectorcraft_engine::file_access::written_elsewhere(path) && vectorcraft_engine::file_access::check_read(path).is_ok() } /// Is `s` a path the request may not read, written as an absolute path (either separator, with or diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 2723dc3da8..d144ffd953 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -263,6 +263,21 @@ fn a_library_saved_with_another_platforms_path_is_hidden() { assert_eq!(hiding.hidden, [(r"loaded/C:\Users\someone\b.svg".to_string(), "loaded/b.svg".to_string())], "{hiding:?}"); } +/// Another platform's absolute path is outside the read root, also when the working folder is inside +/// it: a request that sends one sees it by its file name. +#[test] +fn another_platforms_absolute_path_is_unreadable() { + let here = std::env::current_dir().unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&here], &[]).unwrap(); + let path = r"C:\Users\someone\private.svg"; + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || { + assert!(!super::readable(path) && super::unreadable_absolute(path)); + super::sent_paths(&json!({"list": [path]}), &mut sent); + }); + assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); +} + /// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a /// longer path. #[test] From d4bbce8982c85b4edd548590f08f9772433f7108 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 21:56:02 -0800 Subject: [PATCH 21/35] MCP: hidden library names follow the platform's path rules - Outside Windows a `\` stays part of a hidden library's or sent path's file name; another platform's absolute path is still split on `\`. - A hidden relative path is matched after leading `./` and `../` parts only when they start the path, so `/work/./data/icon.svg` keeps its text. - `ui.inspect` and `ui.docking` replies are hidden once, so a library at the top of the file system shows as `loaded/<file name>` with its separator. --- crates/engine/src/cmd/swatchlib.rs | 19 ++++++---- crates/engine/src/tests/library_roots.rs | 19 ++++++++++ crates/ui-egui/src/control.rs | 39 +++++++++++---------- crates/ui-egui/src/control/tests.rs | 44 ++++++++++++++++++++++-- docs/mcp.md | 3 +- 5 files changed, 95 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 6f007c182d..8101cfff97 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -471,6 +471,15 @@ fn file_name(path: &str) -> String { std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) } +/// The file name a concealed library or path is shown by: the last part of `path` as this platform +/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute +/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one +/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part. +pub fn concealed_file_name(path: &str) -> String { + let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten(); + native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned()) +} + /// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without /// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is /// never readable under a read root. @@ -480,8 +489,8 @@ fn readable(path: &str) -> bool { /// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is -/// named. The file name is the last part of the path after either separator, and a path saved as -/// another platform's absolute path counts as one the request may not read. The ids are those of the +/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute +/// path counts as one the request may not read. The ids are those of the /// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers /// to). When another id already is or shows that id, the next free /// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library @@ -501,11 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin let mut taken = BTreeSet::new(); for ids in [session, others] { for id in ids { - let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| { - // Either separator: a path saved on another platform counts as a path here too. - let name = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(); - (name, path) - }); + let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path)); match name { Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)), _ => { diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 000f0e7532..5ce4a3757e 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -238,6 +238,25 @@ fn swatch_library_replies_leave_out_paths_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// Outside Windows a `\` is part of a file name: a concealed library saved at `<folder>/weird\name.gpl` +/// is shown under its whole file name. Another platform's absolute path is still named by the part +/// after its last `\`. +#[cfg(not(windows))] +#[test] +fn a_concealed_library_keeps_a_backslash_in_its_file_name() { + let base = folder("backslash-name"); + let roots = AutomationRoots::new(&[&base.join("in")], &[]).unwrap(); + let native = format!("loaded/{}/weird\\name.gpl", text(&base.join("a"))); + let foreign = r"loaded/C:\Users\someone\other.gpl".to_string(); + let s = Session::new(); + let mut hidden = confine(roots.as_ref(), || cmd::swatchlib::concealed_library_ids(&s, [native.clone(), foreign.clone()])); + let _ = std::fs::remove_dir_all(base); + hidden.sort(); + let mut want = vec![(foreign, "loaded/other.gpl".to_string()), (native, "loaded/weird\\name.gpl".to_string())]; + want.sort(); + assert_eq!(hidden, want); +} + /// A concealed library keeps the id a client was shown when another library with the same file name, /// whose full id sorts before it, is loaded later: the id still finds the first library. #[test] diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 79d614b9d0..d0f8c28fad 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { fields.remove("floating_panels"); leave_out_unreadable_paths(fields); } - let mut inspected = json!({ + json!({ "scriptRunning": app.session.script_running(), "scriptReport": app.session.shown_script_report(), "tool": app.session.tool_id(), @@ -307,9 +307,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value { // A native file dialog open off the UI thread (`open`, `place`, `save`, `folder`), waiting // for the person at the computer: what asked for it runs again when it answers. "fileDialog": app.picks.waiting_kind(), - }); - hide_library_ids(&mut inspected, &unreadable_library_ids(app)); - inspected + }) } /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel @@ -435,7 +433,10 @@ fn collect_sent_paths(v: &Value, path_param: bool, out: &mut Vec<(String, String Some(None) => percent_decoded(&s[5..]), None => s.to_string(), }; - let Some(name) = path.rsplit(['/', '\\']).find(|part| !part.is_empty()).filter(|name| *name != path) else { return }; + let name = vectorcraft_engine::cmd::swatchlib::concealed_file_name(&path); + if name == path { + return; + } let pair = (format!("loaded/{path}"), format!("loaded/{name}")); if !out.contains(&pair) { out.push(pair); @@ -709,17 +710,22 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { } /// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at -/// `text[at..]` the end of a longer path: is it preceded by a letter, a digit, `_`, `-`, `.`, `~` or a -/// separator other than one after `.` or `..`? `/work/data/icon.svg` does not hold the path -/// `data/icon.svg`; `./data/icon.svg` does. +/// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by +/// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and +/// `/work/./data/icon.svg` do not hold the path `data/icon.svg`; `./data/icon.svg` and +/// `../data/icon.svg` do. fn ends_a_longer_path(text: &str, at: usize, from: &str) -> bool { let absolute = from.starts_with(['/', '\\']) || from.starts_with("loaded/") || matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); - let before = &text[..at]; - !absolute - && !before.ends_with("./") - && !before.ends_with(".\\") - && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) + let mut before = &text[..at]; + while let Some(head) = before.strip_suffix(['/', '\\']) { + let Some(rest) = head.strip_suffix("..").or_else(|| head.strip_suffix('.')) else { break }; + before = rest; + if !before.ends_with(['/', '\\']) { + break; + } + } + !absolute && before.ends_with(|c: char| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | '~' | '/' | '\\')) } /// Does text that follows a path continue it: a letter, a digit, `_` or a separator, after any `.`, @@ -1191,12 +1197,7 @@ fn dispatch(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlRequest) "engine.commands" => ok(all_commands(app)), "document.inspect" => wrap(app.run("document.inspect", p.clone())), "ui.inspect" => ok(inspect(app, ctx)), - "ui.docking" => { - let mut docking = crate::panel_docking::inspect(app, ctx); - let ids = unreadable_library_ids(app); - hide_library_ids(&mut docking, &ids); - ok(docking) - } + "ui.docking" => ok(crate::panel_docking::inspect(app, ctx)), // A request that stops with a panic quoting the status text, after clearing the Color Guide's // limit with `forget`. #[cfg(test)] diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index d144ffd953..7f0a1177a7 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -278,14 +278,54 @@ fn another_platforms_absolute_path_is_unreadable() { assert_eq!(sent, [(format!("loaded/{path}"), "loaded/private.svg".to_string())]); } -/// A hidden relative path is hidden where it stands alone or after `./`, and stays where it ends a -/// longer path. +/// A hidden relative path is hidden where it stands alone or after leading `./` and `../` parts, and +/// stays where it ends a longer path, also one with a `.` or `..` part before it. #[test] fn a_reply_keeps_a_longer_path_that_ends_with_a_hidden_relative_path() { let hidden = hiding(&[("loaded/data/icon.svg", "loaded/icon.svg")], &[]); let mut reply = json!({"result": "kept /work/data/icon.svg and xdata/icon.svg, hid ./data/icon.svg, \"data/icon.svg\" and loaded/data/icon.svg"}); hide_under_a_root("relative", &mut reply, &hidden); assert_eq!(reply["result"], "kept /work/data/icon.svg and xdata/icon.svg, hid ./icon.svg, \"icon.svg\" and loaded/icon.svg", "{reply}"); + let mut reply = json!({"result": "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../data/icon.svg and './../data/icon.svg'"}); + hide_under_a_root("relative-dots", &mut reply, &hidden); + assert_eq!( + reply["result"], "kept /allowed/./data/icon.svg, /allowed/../data/icon.svg and a./data/icon.svg, hid ../icon.svg and './../icon.svg'", + "{reply}" + ); +} + +/// Outside Windows a `\` is part of a file name: a sent path outside the read root is shown by its +/// whole file name. +#[cfg(not(windows))] +#[test] +fn a_sent_path_keeps_a_backslash_in_its_file_name() { + let root = std::env::temp_dir().join(format!("vc-sent-backslash-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let mut sent = Vec::new(); + vectorcraft_engine::file_access::confine(roots.as_ref(), || super::sent_paths(&json!({"path": "/out/weird\\name.svg"}), &mut sent)); + let _ = std::fs::remove_dir_all(root); + assert_eq!(sent, [("loaded//out/weird\\name.svg".to_string(), "loaded/weird\\name.svg".to_string())]); +} + +/// `ui.inspect` and `ui.docking` show a hidden library whose file is at the top of the file system +/// (`/b.svg`) as `loaded/b.svg`, with its separator. +#[test] +fn a_hidden_library_at_the_top_of_the_file_system_keeps_its_shown_id() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let panel = "library:swatches:loaded//b.svg".to_string(); + app.ui.color_guide_limit = "loaded//b.svg".to_string(); + app.ui.open_panel = Some(panel); + let root = std::env::temp_dir().join(format!("vc-top-level-library-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&root], &[]).unwrap(); + let (inspected, docking) = vectorcraft_engine::file_access::confine(roots.as_ref(), || { + (input_request(&mut app, "ui.inspect", json!({})), input_request(&mut app, "ui.docking", json!({}))) + }); + let _ = std::fs::remove_dir_all(root); + assert_eq!(inspected["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{inspected}"); + assert_eq!(inspected["result"]["ui"]["open_panel"], "library:swatches:loaded/b.svg", "{inspected}"); + assert_eq!(docking["result"]["flyout"], "library:swatches:loaded/b.svg", "{docking}"); } /// A path outside the read root that a request sent is shown by its file name in a reply that succeeded diff --git a/docs/mcp.md b/docs/mcp.md index 0d224f247b..c4c649cb3c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -156,7 +156,8 @@ the suite) keep the agent to the folders you give it: Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts - with a drive (`C:`) or a `\` counts as outside the roots. When another hidden library has the same + with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest From 2ec278280a9b6b415f62e9b0f6267eedeebafd00 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Wed, 11 Nov 2026 22:42:59 -0800 Subject: [PATCH 22/35] MCP: ui.dock targets, shortcut keys and native backslash paths keep hidden library ids - ui.dock's move takes a hidden panel's shown id in target, as the other docking destinations do. - Shortcut overrides keyed panel:<panel id>, saved and in an open Keyboard Shortcuts dialog, show the panel id shown for a library outside the read roots, also when nothing else refers to it. - Outside Windows, a native path is matched only as written: a sent /out/weird/name.svg no longer renames a hidden library at /out/weird\name.svg, and a reply does not read one as a spelling of the other. Windows paths are still matched with either separator. --- crates/ui-egui/src/control.rs | 56 ++++++++++++++++++-------- crates/ui-egui/src/control/tests.rs | 14 +++++++ crates/ui-egui/src/tests/automation.rs | 29 +++++++++++++ docs/mcp.md | 11 +++-- 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index d0f8c28fad..ef747f8476 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -320,7 +320,8 @@ const PANEL_FIELDS: [&str; 7] = /// request may not read, each with the id a reply shows instead: `loaded/<file name>`, as a library /// loaded without a path is named. The UI state refers to a library by its bare id in a library panel /// and the Color Guide's limit, by a panel id (`library:<kind>:loaded/<path>`) in the fields of -/// [`PANEL_FIELDS`] and the workspaces' layouts, and by either in the `library` and `panel` params of +/// [`PANEL_FIELDS`], the workspaces' layouts and the keys of shortcut overrides (`panel:<panel id>`, +/// saved and in an open Keyboard Shortcuts dialog), and by either in the `library` and `panel` params of /// recorded actions' steps. Text the person types (a command palette query, a workspace's or an /// action's name) is not read. The session's loaded libraries whose file the request may not read are /// hidden too. The ids are numbered as the library commands number them @@ -332,6 +333,12 @@ fn unreadable_library_ids(app: &VectorcraftApp) -> Hiding { for field in PANEL_FIELDS { collect_library_panel_ids(&ui[field], &mut rest); } + // A shortcut for showing a panel is keyed `panel:<id>`, saved and in an open Keyboard Shortcuts dialog. + for overrides in [&ui["shortcut_overrides"], &ui["dialog"]["fields"]["overrides"]] { + for panel in overrides.as_object().into_iter().flat_map(|o| o.keys()).filter_map(|key| key.strip_prefix("panel:")) { + collect_library_panel_ids(&json!(panel), &mut rest); + } + } collect_library_params(&ui["action_sets"], &mut rest); for workspace in ["custom_workspaces", "workspace_changes"].iter().filter_map(|k| ui[k].as_array()).flatten() { for (_, layout) in workspace.as_object().into_iter().flatten().filter(|(k, _)| k.as_str() != "name") { @@ -389,15 +396,18 @@ impl Hiding { } /// Adds the sent paths of `pairs`, each as a hidden id with the id shown. A hidden id this one holds - /// for the same path (written with either separator, relative or as the absolute path it reads as - /// ([`absolute_form`]), and in any case of ASCII letters with `fold`) is shown as the sent path is. + /// for the same path (written with either separator when it is a Windows path + /// ([`splits_at_backslash`]), relative or as the absolute path it reads as ([`absolute_form`]), and + /// in any case of ASCII letters with `fold`) is shown as the sent path is. fn set_hidden(&mut self, pairs: Vec<(String, String)>, fold: bool) { let key = |id: &str| { let path = id.strip_prefix("loaded/").unwrap_or(id); - let id = absolute_form(path).unwrap_or_else(|| path.to_string()).replace('\\', "/"); - if fold { id.to_ascii_lowercase() } else { id } + let id = absolute_form(path).unwrap_or_else(|| path.to_string()); + let windows = splits_at_backslash(&id); + let id = if windows { id.replace('\\', "/") } else { id }; + (windows, if fold { id.to_ascii_lowercase() } else { id }) }; - let sent: Vec<String> = pairs.iter().map(|(id, _)| key(id)).collect(); + let sent: Vec<(bool, String)> = pairs.iter().map(|(id, _)| key(id)).collect(); for (id, shown) in &mut self.hidden { if let Some(at) = sent.iter().position(|k| *k == key(id)) { shown.clone_from(&pairs[at].1); @@ -499,13 +509,21 @@ fn hide_library_ids(v: &mut Value, hiding: &Hiding) { /// The text [`hide_library_ids`] replaces, in two groups: the hidden libraries' text, then the text of /// the paths the request sent ([`Hiding::sent`]), each longest first. For each id, that is the id with -/// the id shown, and the path in it with the file name shown, the path written with either separator -/// too, and as `{:?}` quotes it (each `\`, quote and control character escaped). A relative path is -/// also replaced as the absolute path the file access check reads it as, against the working folder. +/// the id shown, and the path in it with the file name shown, a Windows path ([`splits_at_backslash`]) +/// written with either separator too, and as `{:?}` quotes it (each `\`, quote and control character +/// escaped). A relative path is also replaced as the absolute path the file access check reads it as, +/// against the working folder. fn hidden_text(hiding: &Hiding) -> Vec<Vec<(String, String)>> { vec![text_pairs(&hiding.hidden), text_pairs(&hiding.sent)] } +/// Is `path` a Windows path, whose parts a `\` separates as well as a `/`: any path on Windows, and +/// elsewhere one that starts with a drive (`C:`) or a `\`? Outside Windows, a `\` in any other path is +/// part of a name. +fn splits_at_backslash(path: &str) -> bool { + cfg!(windows) || path.starts_with('\\') || matches!(path.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()) +} + /// The absolute path that `path`, a relative path (one that does not start with a separator, a drive /// or `~`), reads as from the working folder, as the file access check reads it; `None` for any other /// path. @@ -525,10 +543,15 @@ fn text_pairs(ids: &[(String, String)]) -> Vec<(String, String)> { let path = &id["loaded/".len()..]; let name = shown.strip_prefix("loaded/").unwrap_or(shown); for path in std::iter::once(path.to_string()).chain(absolute_form(path)) { - let (forward, back) = (path.replace('\\', "/"), path.replace('/', "\\")); - let debug = [quoted(&path), quoted(&forward), quoted(&back)].map(|p| (p, quoted(name))); - let plain = [path, forward, back].map(|p| (p, name.to_string())); - for (path, name) in plain.into_iter().chain(debug) { + // A Windows path is also written with the other separator; outside Windows, a native + // path's `\` is part of a name, so that path is written only as it is. + let mut spellings = vec![path.clone()]; + if splits_at_backslash(&path) { + spellings.extend([path.replace('\\', "/"), path.replace('/', "\\")].into_iter().filter(|p| splits_at_backslash(p))); + } + let debug: Vec<_> = spellings.iter().map(|p| (quoted(p), quoted(name))).collect(); + let plain = spellings.into_iter().map(|p| (p, name.to_string())); + for (path, name) in plain.chain(debug) { for (from, to) in [(format!("loaded/{path}"), format!("loaded/{name}")), (path, name)] { if !pairs.iter().any(|(f, _)| *f == from) { pairs.push((from, to)); @@ -1048,9 +1071,10 @@ fn packet_mods(value: Option<&Value>) -> Result<Option<Mods>, String> { /// The params that name a library or a library panel, at any depth of a request (`ui.set {panel}`, /// `engine.execute {params: {panel}}`, `ui.libraryPanel {panel, library}`), with the docking -/// commands' destinations (`window.panel.move {anchor, before}`, `window.panel.float {onto, above, -/// below}`) and a list of panels. -const LIBRARY_PARAMS: [&str; 8] = ["panel", "library", "anchor", "before", "onto", "above", "below", "panels"]; +/// commands' destinations (`window.panel.move {anchor, before}`, `ui.dock {target}`, +/// `window.panel.float {onto, above, below}`) and a list of panels. Only a whole id is translated +/// ([`swap_whole_ids`]), so other text in these params stays. +const LIBRARY_PARAMS: [&str; 9] = ["panel", "library", "anchor", "target", "before", "onto", "above", "below", "panels"]; /// The param of `window.panel.layout` that holds a serialized docking action: each panel id in it is /// translated. diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 7f0a1177a7..0b19350f6f 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -140,6 +140,20 @@ fn a_sent_path_replaces_a_hidden_id_for_the_same_path() { assert_eq!(reply["error"], "can't read \"icon.svg\" or icon.svg", "{reply}"); } +/// Outside Windows, a `\` in a native path is part of a name: a sent path that differs from a hidden +/// library's path only by `/` in place of that `\` names another file, so the library keeps its id +/// and a reply does not read either path as a spelling of the other. +#[cfg(not(windows))] +#[test] +fn a_backslash_in_a_native_path_keeps_two_paths_apart() { + let mut hidden = hiding(&[(r"loaded//out/weird\name.svg", r"loaded/weird\name.svg")], &[]); + hidden.set_hidden(vec![("loaded//out/weird/name.svg".to_string(), "loaded/name.svg".to_string())], false); + assert_eq!(hidden.hidden[0].1, r"loaded/weird\name.svg", "{hidden:?}"); + let mut reply = json!({"error": r"no /out/weird\name.svg, /out/weird/name.svg or \out\weird\name.svg"}); + hide_under_a_root("native-backslash", &mut reply, &hidden); + assert_eq!(reply["error"], r"no weird\name.svg, name.svg or \out\weird\name.svg", "{reply}"); +} + /// A sent path whose text overlaps part of a hidden library's path does not keep that library's path /// from being hidden: the hidden libraries are matched first. #[test] diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 28325fc08e..68cb169c8c 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -1042,6 +1042,17 @@ fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { ); assert_eq!(r["ok"], true, "{r}"); assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + // So does `ui.dock`'s move, whose destination is `target`. + let r = request( + &mut app, + &tx, + "engine.execute", + json!({"command": "ui.dock", "params": {"operation": "move", "panel": "properties", "target": shown, "zone": "tab"}}), + ); + assert_eq!(r["ok"], true, "{r}"); + let layout = crate::panel_docking::layout_of(&app.ui); + assert!(layout.contains(&real) && !layout.contains(&shown.to_string()), "no second panel: {layout:?}"); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); // An error that quotes the panel shows the id the client gave, and other text is not rewritten. let r = request(&mut app, &tx, "ui.set", json!({"panel": "library:bogus:loaded/b.svg"})); assert_eq!(r["ok"], false, "{r}"); @@ -1051,6 +1062,24 @@ fn ui_set_reopens_a_hidden_librarys_panel_by_its_shown_id() { let _ = std::fs::remove_dir_all(base); } +/// A shortcut for showing the panel of a library outside the read root, saved and in an open Keyboard +/// Shortcuts dialog, is keyed by the panel id shown, also when nothing else refers to the library. +#[test] +fn ui_inspect_hides_a_library_panels_path_in_shortcut_overrides() { + let (mut app, tx, base) = confined("shortcut-hidden-panel"); + let real = format!("panel:library:swatches:loaded/{}", s(base.join("outside/b.svg"))); + crate::keymap::set_override(&mut app.ui.shortcut_overrides, &real, "Cmd+Alt+Shift+F7"); + app.ui.dialog = Some(crate::state::Dialog::new("shortcuts", json!({"overrides": { real.clone(): "Cmd+Alt+Shift+F8" }}))); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let shown = "panel:library:swatches:loaded/b.svg"; + let ui = &r["result"]["ui"]; + assert_eq!(ui["shortcut_overrides"][shown], "Cmd+Alt+Shift+F7", "{r}"); + assert_eq!(ui["dialog"]["fields"]["overrides"][shown], "Cmd+Alt+Shift+F8", "{r}"); + let hidden = json!(s(base.join("outside"))).to_string(); + assert!(!r.to_string().contains(hidden.trim_matches('"')), "no path outside the root: {r}"); + let _ = std::fs::remove_dir_all(base); +} + /// A client without automation roots sees every loaded library's full id in `ui.inspect`. #[test] fn ui_inspect_shows_full_library_ids_without_a_read_root() { diff --git a/docs/mcp.md b/docs/mcp.md index c4c649cb3c..5cb8df4df8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -154,10 +154,13 @@ the suite) keep the agent to the folders you give it: empty folder and the scratch disk choices `Startup` and `None` stay). A library loaded from a file outside the roots shows as `loaded/<file name>` wherever `ui_inspect` and `ui.docking` name it: its id, its panel's id, the Color Guide's `ui.color_guide_limit`, a closed panel's key in `ui.docking_hidden` and the `library` and `panel` - params of a recorded action's steps in `ui.action_sets`, also when its panel is closed. A path written with + params of a recorded action's steps in `ui.action_sets`, and the keys of shortcut overrides (`panel:<panel id>` + in `ui.shortcut_overrides` and an open Keyboard Shortcuts dialog's `ui.dialog.fields.overrides`), also when its + panel is closed. A path written with either separator counts as a path, also one saved on another platform, and outside Windows a path that starts with a drive (`C:`) or a `\` counts as outside the roots. Outside Windows, any other path keeps a `\` in its - file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`). When another hidden library has the same + file name (`/libs/a\b.ase` shows as `loaded/a\b.ase`), and a reply matches it only as written, not with `/` + in place of the `\`. When another hidden library has the same file name, the libraries take `loaded/<file name>`, `loaded/<file name> (2)`, `(3)` and so on in the order they are first shown. Libraries first shown in the same reply are numbered in the order of their full ids, the libraries loaded in this session first, then those only the UI state names. Each one keeps its id for the rest @@ -165,8 +168,8 @@ the suite) keep the agent to the folders you give it: file name is loaded. A hidden library skips an id that a library loaded without a path already has, and only a library loaded later from data under that file name, which takes the id itself, moves a hidden one to the next free number. The other entries keep their order, and the app's own state is not changed. A request may name such a library or its panel by the id - shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `before`, `onto`, `above`, - `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` + shown in a `panel`, `library` or `panels` param, in a docking destination (`anchor`, `target`, `before`, `onto`, + `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/<file name>"}` reopens its panel), and the reply to such a request shows that id and file name wherever the library's id or path appears, an error included. Every reply under the read roots shows a hidden library's id as the id shown and its path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query From 07423c22d6acbeb48832cf2fec5ab008d0f390d0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 02:09:47 -0800 Subject: [PATCH 23/35] MCP: save and export replies keep the path the client gave under read roots Under automation read roots, a reply shows each path outside the read roots that the request sent by its file name. A save or export to a path the client gave inside the write roots then answered with the bare file name in `path`, where the save replies keep the given path. The `path` and `files` entries of the reply's result, a `command.batch` step's result included, that equal a path param the request gave inside the write roots now stay as given; every other occurrence is still shown by its file name, and loaded libraries outside the read roots keep their `loaded/<file name>` ids. --- crates/ui-egui/src/control.rs | 76 +++++++++++++++++++++++++- crates/ui-egui/src/tests/automation.rs | 39 +++++++++++++ docs/mcp.md | 5 +- 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index ef747f8476..4ab3982607 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1086,7 +1086,8 @@ const DOCKING_ACTION: &str = "action"; /// such library's id and path as `ui.inspect` does ([`hide_library_ids`]) in every string and key, /// errors included, an error from a panic too: the libraries hidden before it ran and after it. The /// reply also shows each path outside the read root that the request sent by its file name, whether it -/// succeeded or failed ([`sent_paths`]). A reply with no `/`, `\` or `%` in it is not searched, since every hidden path +/// succeeded or failed ([`sent_paths`]), except where a `path` or `files` entry of its result names the +/// file the request wrote inside the write roots, as the request gave it. A reply with no `/`, `\` or `%` in it is not searched, since every hidden path /// holds a separator, written or escaped. A screenshot's reply is hidden when it is sent /// ([`hide_in_deferred_reply`]); a `ui.pointer` reply sent after the tool's edit holds no library id /// or path. @@ -1122,17 +1123,88 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques } /// Hides in `reply` the libraries hidden now and in `before`, and the paths outside the read root that -/// `sent`, the request's params, holds ([`handle`]). +/// `sent`, the request's params, holds ([`handle`]). The `path` and `files` entries of the reply's +/// `result`, at any depth, that name a file the request gave inside the write roots, the file it +/// wrote, stay as given ([`written_paths`]). fn hide_in_reply(app: &VectorcraftApp, reply: &mut Value, sent: &Value, before: Hiding) { if !holds_a_separator(reply) { return; } + let mut given = Vec::new(); + written_paths(sent, false, &mut given); + let kept = kept_written_paths(reply, &given); let mut hiding = unreadable_library_ids(app); hiding.merge(before); let mut paths = Vec::new(); sent_paths(sent, &mut paths); hiding.set_hidden(paths, cfg!(windows)); hide_library_ids(reply, &hiding); + restore_written_paths(reply, kept); +} + +/// The strings that `v`'s path params hold at any depth ([`vectorcraft_engine::file_access::is_path_key`]) +/// that name a file inside the write roots; `v` is a path param's value when `path_param` is set. +fn written_paths(v: &Value, path_param: bool, out: &mut Vec<String>) { + use vectorcraft_engine::file_access::{Access, current, is_path_key}; + match v { + Value::String(s) if path_param && !s.is_empty() && current().is_some_and(|r| r.check(s, Access::Write).is_ok()) => out.push(s.clone()), + Value::Array(items) => items.iter().for_each(|item| written_paths(item, path_param, out)), + Value::Object(fields) => { + for (key, item) in fields { + written_paths(item, is_path_key(key), out); + } + } + _ => {} + } +} + +/// The entries under `reply`'s `result`, at any depth (a `command.batch` step's result too), that a +/// `path` key or a `files` list holds and that are one of `given`: each entry's JSON pointer and text. +fn kept_written_paths(reply: &Value, given: &[String]) -> Vec<(String, String)> { + let mut kept = Vec::new(); + if !given.is_empty() { + kept_under(&reply["result"], "/result", given, &mut kept); + } + kept +} + +/// [`kept_written_paths`] for `v`, found at the JSON pointer `at`. +fn kept_under(v: &Value, at: &str, given: &[String], out: &mut Vec<(String, String)>) { + let is_given = |v: &Value| v.as_str().filter(|s| given.iter().any(|g| g == s)).map(str::to_string); + match v { + Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + kept_under(item, &format!("{at}/{i}"), given, out); + } + } + Value::Object(fields) => { + for (key, item) in fields { + let here = format!("{at}/{}", key.replace('~', "~0").replace('/', "~1")); + match (key.as_str(), is_given(item), item.as_array()) { + ("path", Some(path), _) => out.push((here, path)), + ("files", _, Some(rows)) => { + for (i, row) in rows.iter().enumerate() { + match is_given(row) { + Some(path) => out.push((format!("{here}/{i}"), path)), + None => kept_under(row, &format!("{here}/{i}"), given, out), + } + } + } + _ => kept_under(item, &here, given, out), + } + } + } + _ => {} + } +} + +/// Writes back in `reply` the entries [`kept_written_paths`] took from it. +fn restore_written_paths(reply: &mut Value, kept: Vec<(String, String)>) { + for (at, path) in kept { + if let Some(slot) = reply.pointer_mut(&at) { + *slot = json!(path); + } + } } /// Hides in a reply sent after its request was handled (a screenshot's) what [`handle`] hides in a diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 68cb169c8c..073cefbd82 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -128,6 +128,45 @@ fn control_saves_leave_out_the_documents_file_outside_the_read_root() { let _ = std::fs::remove_dir_all(base); } +/// In one session with a library loaded from outside the read root: the library keeps its file-name +/// id, a reply that names its path shows the file name, a save without a path leaves out the +/// document's file, and a save or export to a path the client gave in the write root keeps that path +/// in `path` and `files`, also in a `command.batch` step's result. A refused path outside the write root is still shown by its file name. +#[test] +fn control_saves_keep_the_given_path_beside_a_hidden_library() { + let (mut app, tx, base) = confined("save-and-library"); + let library = s(base.join("outside/b.svg")); + app.ui.color_guide_limit = format!("loaded/{library}"); + app.run("file.new", json!({"width": 100, "height": 50})).unwrap(); + let own = s(base.join("out/doc.vectorcraft")); + app.run("file.saveAs", json!({"path": own})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let r = request(&mut app, &tx, "app.save", json!({})); + assert!(r["result"].get("path").is_none() && !r.to_string().contains(&s(base.join("out"))), "{r}"); + let r = request(&mut app, &tx, "app.save", json!({"path": own})); + assert_eq!(r["result"]["path"], json!(own), "{r}"); + let split = s(base.join("out/split.vectorcraft")); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "file.save", "params": {"path": split, "separateArtboards": true}})); + assert_eq!(r["result"]["path"], json!(split), "{r}"); + assert_eq!(r["result"]["files"][0], json!(split), "{r}"); + let batched = s(base.join("out/batched.vectorcraft")); + let steps = json!({"commands": [{"command": "document.save", "params": {"path": batched}}]}); + let r = request(&mut app, &tx, "engine.execute", json!({"command": "command.batch", "params": steps})); + assert_eq!(r["result"]["results"][0]["path"], json!(batched), "{r}"); + let svg = s(base.join("out/a.svg")); + let r = request(&mut app, &tx, "app.export", json!({"path": svg, "wait": true})); + assert_eq!(r["result"]["path"], json!(svg), "{r}"); + app.ui.status = format!("from {library}"); + let r = request(&mut app, &tx, "test.status", json!({})); + assert_eq!(r["result"], "from b.svg", "{r}"); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + assert_eq!(r["result"]["ui"]["color_guide_limit"], "loaded/b.svg", "{r}"); + let refused = s(base.join("outside/c.vectorcraft")); + let r = request(&mut app, &tx, "app.save", json!({"path": refused, "wait": true})); + assert!(r["error"].as_str().is_some_and(|e| e.contains("outside the write root") && !e.contains(&refused)), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// With Background Save and Background Export on, `app.save` and `app.export` with `wait: true` /// (MCP's `save_file` in the app) reply with the write root's refusal, and write a file inside it /// before they reply. Without `wait` the refusal reaches only the status bar. diff --git a/docs/mcp.md b/docs/mcp.md index 5cb8df4df8..03bb0b9665 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -178,7 +178,10 @@ the suite) keep the agent to the folders you give it: URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it and whether the - request succeeded or failed, so that the reply reads the same either way. A path param (`path`, `file`, + request succeeded or failed, so that the reply reads the same either way. The one exception is the `path` + and `files` entries of a reply's result, a `command.batch` step's result included, that name the file the + request wrote inside the write roots at the path the request gave: they stay as + given, as for the save and export replies above. A path param (`path`, `file`, `destinationFolder` and the like) counts with a relative path too. The hidden libraries' ids and paths are matched before the paths the request sent, so a sent path written over part of a hidden library's path does not keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` From b4ffd4a97ec8554f07d8448ccf991e0fe3d61b46 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 06:24:28 -0800 Subject: [PATCH 24/35] MCP: a hidden path's file: URL shows as the file name, and rewritten keys keep every field Under automation read roots, a reply replaced only the path inside a `file:///outside/a.svg` URL, which left `file://a.svg`, a URL that names a host. The whole URL of an absolute path, with `localhost`, another host or no host, is now replaced with the file name, unless part of its scheme was already replaced. A reply key rewritten to read the same as another key of its object took that key's place and its field was lost; the rewritten key now takes ` (2)`, ` (3)` and so on, and a key that is not rewritten keeps its name. --- crates/ui-egui/src/control.rs | 46 ++++++++++++++++++++++++-- crates/ui-egui/src/control/tests.rs | 33 +++++++++++++++--- crates/ui-egui/src/tests/automation.rs | 4 +-- docs/mcp.md | 3 +- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4ab3982607..f8022a3593 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -583,7 +583,8 @@ fn is_base64(s: &str) -> bool { /// Writes each `from` text of the rewrite's pairs as its `to` text in every string and key of `v`, as /// [`replace_text`] does. Encoded file data, a base64 string under a key of [`BASE64_FIELDS`], stays -/// as it is. +/// as it is. A rewritten key that reads the same as another key of its object takes ` (2)`, ` (3)` +/// and so on; a key that is not rewritten keeps its name. fn replace_in_value(v: &mut Value, r: &Rewrite) { match v { Value::String(s) => { @@ -593,13 +594,30 @@ fn replace_in_value(v: &mut Value, r: &Rewrite) { } Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), Value::Object(fields) => { + // The keys that stay are written first, so a rewritten key that reads as one of them, or + // as another rewritten key, is the one numbered and no field is lost. + let mut rewritten = Vec::new(); for (key, mut item) in std::mem::take(fields) { if BASE64_FIELDS.contains(&key.as_str()) && item.as_str().is_some_and(is_base64) { fields.insert(key, item); continue; } replace_in_value(&mut item, r); - fields.insert(replace_text(&key, r).unwrap_or(key), item); + match replace_text(&key, r) { + Some(written) => rewritten.push((written, item)), + None => { + fields.insert(key, item); + } + } + } + for (written, item) in rewritten { + let mut key = written.clone(); + let mut n = 2; + while fields.contains_key(&key) { + key = format!("{written} ({n})"); + n += 1; + } + fields.insert(key, item); } } _ => {} @@ -681,6 +699,10 @@ impl<'a> Found<'a> { match begins_known_id(text, at, end, r) { Some(len) => self.taken.push(span(at, at + len)), None => { + // A `file:` URL of the path is replaced whole, so no `file://<name>` is left that + // would name a host, unless part of its scheme was taken already. + let url = span(url_start(text, at, from), end).0; + let start = if self.taken.iter().any(|&(a, b)| url < b && a < stop) { start } else { url }; self.taken.push((start, stop)); self.cuts.push((start, stop, to)); } @@ -689,6 +711,26 @@ impl<'a> Found<'a> { } } +/// Where a `file:` URL that holds `from`, an absolute path found at `at` in `text`, begins: `at` less +/// the `file:` before it, with `//` and a host (`localhost`, another host or none) between them, in any +/// case of its letters; before a drive, the host is followed by a `/` too. `at` when no such URL holds it. +fn url_start(text: &str, at: usize, from: &str) -> usize { + let drive = matches!(from.as_bytes(), [drive, b':', ..] if drive.is_ascii_alphabetic()); + if !from.starts_with('/') && !drive { + return at; + } + let Some(before) = text.get(..at) else { return at }; + let Some(scheme) = before.to_ascii_lowercase().rfind("file:") else { return at }; + let rest = &before[scheme + "file:".len()..]; + let host = |h: &str| h.chars().all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']' | '%')); + let holds = if drive { + rest.strip_prefix("//").and_then(|r| r.strip_suffix('/')).is_some_and(host) + } else { + rest.is_empty() || rest.strip_prefix("//").is_some_and(host) + }; + if holds { scheme } else { at } +} + /// `text` with its `%XX` escapes decoded, and for each byte of the decoded text the range of `text` /// it was read from. A decoded byte that is not part of UTF-8 text is read as U+FFFD. fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 0b19350f6f..babc710067 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -91,7 +91,8 @@ fn a_reply_keeps_a_hidden_path_only_inside_a_known_library_id() { } /// With matching in any case (as on Windows), a hidden path written in other letter cases is hidden -/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform. +/// too; a path in a `file:` URL with `%XX` escapes is hidden on every platform, the whole URL shown as +/// the file name. #[test] fn a_reply_hides_a_path_in_any_case_and_escaped() { let hidden = hiding(&[("loaded/C:/Libs/My Swatches/b.svg", "loaded/b.svg")], &[]); @@ -99,7 +100,7 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { let pairs = super::hidden_text(&hidden); let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; super::replace_in_value(&mut reply, &rewrite); - assert_eq!(reply["error"], "from b.svg and file:///b.svg", "{reply}"); + assert_eq!(reply["error"], "from b.svg and b.svg", "{reply}"); // Two libraries whose paths differ only in letter case keep their own ids. let two = hiding(&[("loaded//libs/b.svg", "loaded/b.svg"), ("loaded//libs/B.svg", "loaded/B.svg")], &[]); let mut reply = json!({"error": "x /libs/B.svg y /libs/b.svg z /LIBS/b.svg"}); @@ -112,11 +113,22 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { assert_eq!(reply["error"], "LOADED//LIBS/IN/A.SVG", "{reply}"); let mut reply = json!({"error": "file:///C:/Libs/My%20Swatches/b.svg"}); hide_under_a_root("escaped", &mut reply, &hidden); - assert_eq!(reply["error"], "file:///b.svg", "{reply}"); + assert_eq!(reply["error"], "b.svg", "{reply}"); let hidden = hiding(&[("loaded//libs/caf\u{e9}/b.svg", "loaded/b.svg")], &[]); let mut reply = json!({"error": "file:///libs/caf%C3%a9/b.svg and file:///libs%2Fcaf%c3%A9%2Fb.svg?next=%2Fdocs&x=%FF at 100%25"}); hide_under_a_root("escaped-mixed", &mut reply, &hidden); - assert_eq!(reply["error"], "file://b.svg and file://b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); + assert_eq!(reply["error"], "b.svg and b.svg?next=%2Fdocs&x=%FF at 100%25", "{reply}"); + // A `file:` URL without escapes is replaced whole too, with `localhost` or another host. + let mut reply = json!({"error": "file:///libs/caf\u{e9}/b.svg and FILE://localhost/libs/caf%C3%A9/b.svg"}); + hide_under_a_root("plain-url", &mut reply, &hidden); + assert_eq!(reply["error"], "b.svg and b.svg", "{reply}"); + let mut reply = json!({"error": "file://remote/libs/caf\u{e9}/b.svg; file:/libs/caf\u{e9}/b.svg; file: x/libs/caf\u{e9}/b.svg"}); + hide_under_a_root("other-host", &mut reply, &hidden); + assert_eq!(reply["error"], "b.svg; b.svg; file: xb.svg", "{reply}"); + let hidden = hiding(&[("loaded///host/share/c.svg", "loaded/c.svg")], &[]); + let mut reply = json!({"error": "file://host/share/c.svg, file:////host/share/c.svg, file://localhost//host/share/c.svg"}); + hide_under_a_root("host-url", &mut reply, &hidden); + assert_eq!(reply["error"], "c.svg, c.svg, c.svg", "{reply}"); } /// A path a failed request sent takes the place of a hidden library's id for the same path, written @@ -263,6 +275,19 @@ fn a_reply_replaces_each_hidden_text_once() { let mut reply = json!({"error": "file:///art/in%20work/a.svg"}); hide_under_a_root("escaped-readable", &mut reply, &hidden); assert_eq!(reply["error"], "file:///art/in%20work/a.svg", "{reply}"); + // A URL whose scheme another hidden path took already is cut at its path. + let hidden = hiding(&[("loaded//a", "loaded/a"), ("loaded/file", "loaded/file")], &[]); + let mut reply = json!({"error": "file:///a"}); + hide_under_a_root("scheme-taken", &mut reply, &hidden); + assert_eq!(reply["error"], "file://a", "{reply}"); + // Keys that come to read the same keep every field; a key that is not rewritten keeps its name. + let hidden = hiding(&[("loaded//out/a.svg", "loaded/a.svg")], &[]); + let mut reply = json!({"result": {"a.svg": 1, "file:///out/a.svg": 2, "/out/a.svg": 3}}); + hide_under_a_root("same-keys", &mut reply, &hidden); + assert_eq!(reply["result"]["a.svg"], 1, "{reply}"); + let mut values: Vec<_> = reply["result"].as_object().map(|o| o.values().filter_map(Value::as_u64).collect()).unwrap_or_default(); + values.sort_unstable(); + assert_eq!(values, [1, 2, 3], "{reply}"); } /// A library saved with another platform's absolute path is hidden, also when the working folder is diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 073cefbd82..0ed949edf5 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -786,7 +786,7 @@ fn a_screenshot_reply_hides_paths_like_any_reply() { } /// A reply whose only trace of a hidden library's path is a `file:` URL with every separator escaped -/// shows the file name. +/// shows the file name alone. #[test] fn a_reply_hides_a_path_with_escaped_separators() { let (mut app, tx, base) = confined("escaped-separators"); @@ -794,7 +794,7 @@ fn a_reply_hides_a_path_with_escaped_separators() { app.ui.color_guide_limit = format!("loaded/{path}"); app.ui.status = format!("from file:{}", path.replace('/', "%2F").replace('\\', "%5C")); let r = request(&mut app, &tx, "test.status", json!({})); - assert_eq!(r["result"], "from file:b.svg", "{r}"); + assert_eq!(r["result"], "from b.svg", "{r}"); let _ = std::fs::remove_dir_all(base); } diff --git a/docs/mcp.md b/docs/mcp.md index 03bb0b9665..faae559e61 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -175,7 +175,8 @@ the suite) keep the agent to the folders you give it: path as the file name in every string and key, the status text (`ui.status`), dialogs, the command palette query (`ui.palette_query`), a script's report (`scriptReport`), a screenshot's reply and the error from an internal fault included, for the libraries hidden before the request ran and after it. The path is also matched with `%XX` escapes as a `file:` - URL writes it, and on Windows in any case of its letters. A hidden id or path stays only where it begins + URL writes it, and on Windows in any case of its letters; a `file:` URL of an absolute path is shown as the file + name alone, without `file:` or its host. A hidden id or path stays only where it begins another loaded library's whole id with no `.` or `..` part, so a readable library's id that begins with a hidden one stays. A reply also shows a path outside the read roots that the request sent by its file name, whether or not a library was loaded from it and whether the request succeeded or failed, so that the reply reads the same either way. The one exception is the `path` From fd81eae9b582f15b6c5cbadc2caab748fee67024 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:20:37 -0800 Subject: [PATCH 25/35] Type: text.create and the type style commands refuse a size outside 0.1 to 1296 pt text.create clamped a size above 1296 pt to 1296 without an error, so a request for 2835 pt type returned 1296 pt type. text.createInPath, text.setStyle and text.setRangeStyle clamped the same way. Each now refuses a size outside 0.1 to 1296 pt with "`size` must be from 0.1 to 1296" and changes nothing. Both ends of the range are still taken. Follows #6643. --- crates/engine/src/cmd/addons/color/legend.rs | 2 +- crates/engine/src/cmd/addons/typetools.rs | 3 +- crates/engine/src/cmd/clipboard/flavours.rs | 2 +- crates/engine/src/cmd/create.rs | 12 +++--- crates/engine/src/cmd/textedit.rs | 11 ++--- crates/engine/src/cmd/typecmd.rs | 21 +++++++--- crates/engine/src/tests/textedit.rs | 44 ++++++++++++++++++++ docs/mcp.md | 4 +- 8 files changed, 77 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/cmd/addons/color/legend.rs b/crates/engine/src/cmd/addons/color/legend.rs index e2f38a7a17..e6942378be 100644 --- a/crates/engine/src/cmd/addons/color/legend.rs +++ b/crates/engine/src/cmd/addons/color/legend.rs @@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result<Value> { let parent = st.target_parent()?; let text_size = (size / 7.0).clamp(4.0, 12.0); let pad = (size / 12.0).clamp(1.0, 8.0); - let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size })); + let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }), C)?; let count = swatches.len(); // Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or // spot swatch then recolours the legend too. diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 537e6bfe75..10a00b1b45 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result<Value> { let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; - let value = from + (to - from) * amount(shape, i, n, random); + // Rounding can carry a value a hair past the range's ends. + let value = (from + (to - from) * amount(shape, i, n, random)).clamp(lo, hi); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; while ends.get(run).is_some_and(|&e| e <= at) { diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 9463dd8cf1..e98dbd5619 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> { if text.trim().is_empty() { return Err(bad(C, "the text is empty")); } - let style = super::super::create::new_type_style(s, &Value::Null); + let style = super::super::create::new_type_style(s, &Value::Null, C)?; let system_layout = s.doc()?.doc.setup.system_layout; let layout = |t: &mut TextObject| { t.system_layout = system_layout; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 37473cc59f..ebef729fd3 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt 0.1..1296 (outside it: an error, and no type is made), font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -739,7 +739,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> { check_numbers(a, &["width", "height"], "text.create")?; } let text = str_param(p, "text").unwrap_or(""); - let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p, "text.create")?); // Turned about the point (the first baseline's start, an area's top-left corner): type set // along a Constrain Angle. let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 }; @@ -848,11 +848,11 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB /// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults, /// filled with the current fill unless that is None or white (black then), in the document's -/// language. -pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { +/// language. A `size` outside [`super::typecmd::TYPE_SIZES`] is an error. +pub(crate) fn new_type_style(s: &Session, p: &Value, cmd: &str) -> Result<CharStyle> { let mut style = CharStyle::default(); if let Some(sz) = p.get("size").and_then(Value::as_f64) { - style.size = sz.clamp(0.1, 1296.0); + style.size = super::typecmd::given_type_size(sz, cmd)?; } if let Some(f) = str_param(p, "font") { style.font_family = f.to_string(); @@ -873,5 +873,5 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten(); let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language)); style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string); - style + Ok(style) } diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index edfab850f5..d75ea13d6e 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt, leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{id? (default: the one selected type object), start?: byte, end?: byte (default: all text), story?: bool = false (all the text of every frame of the threaded story `id` is a frame of, in one undo step; no `start` or `end`), font?, style?, size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?, kerning?: 1/1000 em|\"auto\"|\"optical\" (the space after each character), kerningBefore?: 1/1000 em -1000..10000 (the space before a character that starts a line by itself: the first character of the text, of a paragraph or after a forced line break; no effect elsewhere; 0 by default), baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), fill?: colour|\"none\", stroke?: colour|\"none\" (#rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt, strokeOptions?: {weight?, cap?, join?, miterLimit?, align?, dash?, dashOffset?, alignDashes?} (as stroke.set: the character stroke), underline?, strikethrough?, allCaps?: bool, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), features?: [\"dlig\", \"-liga\", …], featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\", proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings: in vertical type a block of tate-chu-yoko characters moves up by a positive Up/Down and down by a negative one, right by a positive Left/Right and left by a negative one, as one piece with the values of its first character; on a vertical path a positive Up/Down moves it toward the path's start and a positive Left/Right to the path's left; 0 by default; automatic digit blocks and horizontal type do not move; neither turns tateChuYoko on), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, with Show East Asian Options: in vertical type those half-width characters, such as Latin letters and digits, stand upright one below the other instead of being rotated; spaces and tate-chu-yoko blocks are not changed; kept on the characters and ignored in horizontal type; off by default), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference: the height of the font that size measures, the em box by default; with a font that has no such height, or one outside 0.1 to 2 em, the em box), language?: name|tag (Character panel › Language: one of Document Setup's languages by name or BCP 47 tag, case-insensitive, such as \"Turkish\" or \"tr\"; the characters are shaped in it, so the font uses that language's forms (OpenType `locl`); saved as the tag, English: USA, the default, as none; new type takes the document's language), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font: the setting of each variation axis named, in the axis's units and clamped to its range; null: back to the style's own setting; other axes keep theirs; tags the font has no axis for are left out; a font or style change clears them; text.fontList {family, style} lists the axes)} style a character range (size/leading/baselineShift are document points and hScale includes the object transform; runs are split at the range ends) → {id, runs, warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_range_style ), @@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> { "Area / Path Type", [], None, - "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", + "{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?: pt 0.1..1296 (outside it: an error), font?, style?, color?, keepModel? (as text.create), fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, bounds: {x, y, width, height} (as text.create), overflow?: [id] (the text doesn't fit; left out when it fits)}", has_doc, create_in_path ), @@ -713,7 +713,7 @@ impl CharChange { let c = Self { font: text_param(p, "font", cmd)?.map(str::to_string), style: text_param(p, "style", cmd)?.map(str::to_string), - size: num("size"), + size: num("size").map(|v| super::typecmd::given_type_size(v, cmd)).transpose()?, leading: auto_or_num(p, "leading", cmd)?, tracking: num("tracking"), kerning: match p.get("kerning") { @@ -757,9 +757,6 @@ impl CharChange { font_axes: font_axes_param(p, cmd)?, font_height_reference: font_height_reference_param(p, cmd)?, }; - if c.size.is_some_and(|v| v <= 0.0) { - return Err(bad(cmd, "size must be positive")); - } Ok(c) } @@ -1610,7 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> { check_numbers(p, &["size"], C)?; check_switches(p, &["vertical", "placeholder"], C)?; super::color_param(p, "color", C)?; - let style = super::create::new_type_style(s, p); + let style = super::create::new_type_style(s, p, C)?; let text = str_param(p, "text").unwrap_or("").to_string(); let start = match point_param(p, "at") { Some(at) if on_path => vectorcraft_geom::ArcPath::new(path).fraction_at(at).unwrap_or(0.0), diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 06368c6c7d..b2498a198c 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character", [], None, - "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", + "{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", …] OpenType, featureChanges?: [\"tnum\", \"-onum\", …] (OpenType features turned on, or with - off, on top of each character's own; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}", has_doc, set_style ), @@ -715,6 +715,20 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node Ok(t) } +/// The type sizes a command takes, in points. +pub(crate) const TYPE_SIZES: (f64, f64) = (0.1, 1296.0); + +/// A type size `size` that a command was given: an error naming the range when it is outside +/// [`TYPE_SIZES`]. +pub(crate) fn given_type_size(size: f64, cmd: &str) -> Result<f64> { + let (lo, hi) = TYPE_SIZES; + if !(lo..=hi).contains(&size) { + let key = "size"; + return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}"))); + } + Ok(size) +} + /// Clamp in the units shown to the user, then compensate the text's affine. Clamping /// the local result would make small sizes impossible after enlarging an object. pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> { @@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "text.setStyle"; let font = text_param(p, "font", C)?.map(str::to_string); let style = text_param(p, "style", C)?.map(str::to_string); - let size = length_given(p, "size", C)?; - if size.is_some_and(|v| v <= 0.0) { - return Err(bad(C, "size must be positive")); - } + let size = length_given(p, "size", C)?.map(|v| given_type_size(v, C)).transpose()?; let leading = match p.get("leading") { None | Some(Value::Null) => None, Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), diff --git a/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index abb39c0d96..96a7575360 100644 --- a/crates/engine/src/tests/textedit.rs +++ b/crates/engine/src/tests/textedit.rs @@ -87,6 +87,50 @@ fn set_range_style_splits_runs() { assert!(s.execute("text.setRangeStyle", &json!({"id": id.0, "size": -1})).is_err()); } +/// `text.create`, `text.createInPath`, `text.setStyle` and `text.setRangeStyle` refuse a size +/// outside 0.1 to 1296 pt with an error naming the range, and change nothing; both ends are taken. +#[test] +fn a_type_size_outside_its_range_is_refused() { + let mut s = session(); + let message = "`size` must be from 0.1 to 1296"; + let state = |s: &Session| (s.doc().unwrap().doc.node_count(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for size in [2835.0, 1296.5, 0.05, 0.0, -1.0] { + let e = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": size})).unwrap_err().to_string(); + assert!(e.contains(message), "{size}: {e}"); + } + assert_eq!(state(&s), before, "no type is made"); + let big = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Big", "size": 1296})).unwrap(); + let big = NodeId(big["id"].as_u64().unwrap()); + assert_eq!(obj(&s, big).runs[0].style.size, 1296.0); + let small = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "small", "size": 0.1})).unwrap(); + assert_eq!(obj(&s, NodeId(small["id"].as_u64().unwrap())).runs[0].style.size, 0.1); + let path = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let before = state(&s); + let e = s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 2835})).unwrap_err().to_string(); + assert!(e.contains(message), "{e}"); + assert_eq!(state(&s), before); + s.execute("text.createInPath", &json!({"path": path, "text": "In", "size": 1296})).unwrap(); + let id = text(&mut s, "Hello"); + let before = state(&s); + for (cmd, params) in [ + ("text.setStyle", json!({"ids": [id.0], "size": 2835})), + ("text.setStyle", json!({"ids": [id.0], "size": "1300 pt"})), + ("text.setStyle", json!({"ids": [id.0], "size": 0.05})), + ("text.setRangeStyle", json!({"id": id.0, "start": 0, "end": 2, "size": 2835})), + ("text.setRangeStyle", json!({"id": id.0, "size": 0})), + ] { + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains(message), "{cmd} {params}: {e}"); + } + assert_eq!(state(&s), before, "nothing changes"); + assert_eq!(obj(&s, id).runs[0].style.size, 20.0); + s.execute("text.setStyle", &json!({"ids": [id.0], "size": 1296})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 1296.0); + s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 0, "end": 1, "size": 0.1})).unwrap(); + assert_eq!(obj(&s, id).runs[0].style.size, 0.1); +} + /// `featureChanges` turns features on and off on top of each run's own and keeps the rest, in /// `text.setStyle` and `text.setRangeStyle`, in one undo step; an unknown tag is an error. #[test] diff --git a/docs/mcp.md b/docs/mcp.md index fa22c66414..33ed414242 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1568,7 +1568,9 @@ wrong. A value that is not a number gets `` `dx` must be a number, not "10" ``: `` `size` must be a number, not true ``, and a `tracking` that is neither a number nor a number written as a string (`"20"`) with `` `tracking` must be a number, not false ``. It and `text.setRangeStyle` refuse a `font` or `style` that is not a string with `` `font` must be a string ``, and a `features` list with an entry that is not a string with -`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `type.ruby` refuses a `text`, +`features must be a list of tags`; `text.setStyle` refuses such a `justify` too. `text.create`, `text.createInPath`, +`text.setStyle` and `text.setRangeStyle` refuse a `size` outside 0.1 to 1,296 pt with +`` `size` must be from 0.1 to 1296 `` and change nothing. `type.ruby` refuses a `text`, `position` or `align` that is not a string, and `text.setPathHandle` refuses a `which` other than `in` or `out` with `` `which` must be one of in, out ``. Each leaves the document, the selection and the undo history as they were, also when the call gives another param it could apply. A null is read as a key left out. From 2defb4900209463c20734865301ffebef431cfcf Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:06:17 -0800 Subject: [PATCH 26/35] Font menu: one wide layout wherever it opens, with larger samples The font menu opened at its field's width, so from the Control bar's narrow Character field the filters wrapped one chip per line and family names were cut off after a few letters. - The filters keep the same four rows wherever the menu opens: the scripts; the star, Any Kind, Serif / Mincho, Sans Serif / Gothic and Rounded; Script / Brush, Monospaced and Decorative; Font Technology with Variable. - The menu opens at least wide enough for those rows and for a family name up to 220 points wide with room for its sample, up to 60% of the window, and stays in the window. A field wider than that keeps its width. - Where the row has room, the sample starts after the widest name instead of halfway along the row. It takes the row's whole height instead of 4 points less, so samples draw larger at every Font Preview Size. Requested by POGO. --- crates/ui-egui/src/font_menu.rs | 243 +++++++++++++++++++++----- crates/ui-egui/src/font_menu/tests.rs | 97 ++++++++++ crates/ui-egui/src/tests/font_menu.rs | 139 +++++++++++++++ docs/type-parity.md | 2 +- 4 files changed, 435 insertions(+), 46 deletions(-) diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 0014047657..486cfd682f 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -6,9 +6,12 @@ //! back. The Glyphs panel and Find Font only pick a font ([`picked`]): nothing is previewed on the //! document. A star marks a favourite family, and the ★ filter lists only those. The Font //! Technology filter's Variable lists only variable font families. Preferences › Type › Enable -//! in-menu font previews and Font Preview Size turn the samples off or size the rows. Recently -//! used families appear first, in the order they were last applied, within the same search and -//! filters as the remaining families. In the Character and Properties panels and the Control bar, +//! in-menu font previews and Font Preview Size turn the samples off or size the rows. The menu lays +//! out its filters on the same four rows wherever it opens. However narrow its field, it opens at +//! least wide enough for those rows and for a family name up to 220 points wide with its sample +//! beside it, up to 60% of the window; each sample fills its row's height. Recently used families +//! appear first, in the order they were last applied, within the same search and filters as the +//! remaining families. In the Character and Properties panels and the Control bar, //! the mouse wheel turned over the closed field applies the next family (or previous one) to the //! selected text, one undo step per notch. The menu's order at the first turn stays fixed until the //! pointer leaves the field or the text selection changes; steps stop at its first and last family. @@ -132,10 +135,29 @@ impl<'a> MenuLook<'a> { const ROW: f32 = 24.0; /// Width of the star at the end of a family's row. const STAR: f32 = 18.0; -/// The share of a row the family's name takes; the sample fills the rest. +/// The least share of a row the family's name takes when the row is too narrow for the widest +/// name and [`SAMPLE_W`]; the sample fills the rest. const NAME_SHARE: f32 = 0.5; +/// Size of a family's name in its row. +const NAME_SIZE: f32 = 12.0; +/// The widest name the menu widens itself for. A longer name takes more of a row only in a menu +/// wider than that. +pub(crate) const NAME_MAX: f32 = 220.0; +/// Space between a family's name and its sample. +const NAME_GAP: f32 = 8.0; +/// How much wider than the sum of its characters' advances a name may lay out once shaped +/// (kerning, ligatures, contextual forms), as a share of that sum. +const SHAPING: f32 = 0.1; +/// The room the menu keeps for a sample beside the widest name. +const SAMPLE_W: f32 = 150.0; +/// The greatest share of the window's width the menu takes. +const WINDOW_SHARE: f32 = 0.6; +/// Size of the filters' labels. +const FILTER_SIZE: f32 = 11.0; /// Width of the disclosure triangle of a family with several styles. const DISCLOSURE: f32 = 14.0; +/// How far a family's style rows are indented past the triangle. +const STYLE_INDENT: f32 = 12.0; /// The list's greatest height, under the search field and filters. const LIST_HEIGHT: f32 = 600.0; /// Samples rendered (or asked for) per frame, across every menu, so a long list doesn't stall the UI. @@ -289,6 +311,8 @@ struct MenuState { rows_key: Option<RowsKey>, /// The height of the search field and filters last frame. header: f32, + /// The width of the widest row of filters without wrapping, last frame. + filters: f32, /// The families the wheel steps through, listed at its first notch over the closed field and /// kept for that text selection while the pointer stays on it: each step makes its family a recent one, which would /// otherwise move it in the order. @@ -321,7 +345,11 @@ pub fn font_menu( // of families. let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| { menu.height(height).show_rows_with_header( - |ui| header(ui, state_id, state, current, look), + |ui| { + // Wider than a narrow field, so the filters keep their rows and long names show. + ui.set_min_width(menu_width(ui, state, look)); + header(ui, state_id, state, current, look) + }, look.row, |h| h.rows.len(), |ui, range, h| rows(ui, range, h, sample, look), @@ -957,42 +985,7 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & ui.memory_mut(|m| m.request_focus(field.with("edit"))); } let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase(); - // The filters: script, then kind, then font technology. - let mut refilter = false; - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - for i in 0..SCRIPTS { - if ui.selectable_label(st.script == i, egui::RichText::new(script_label(i)).size(11.0)).clicked() { - st.script = i; - refilter = true; - } - } - }); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - if ui.selectable_label(st.favorites_only, egui::RichText::new("★").size(11.0)).on_hover_text(tl!("Favorites")).clicked() { - st.favorites_only = !st.favorites_only; - refilter = true; - } - if ui.selectable_label(st.class.is_none(), egui::RichText::new(tl!("Any Kind")).size(11.0)).clicked() { - st.class = None; - refilter = true; - } - for c in FontClass::ALL { - if ui.selectable_label(st.class == Some(c), egui::RichText::new(class_label(c)).size(11.0)).clicked() { - st.class = if st.class == Some(c) { None } else { Some(c) }; - refilter = true; - } - } - }); - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = vec2(4.0, 2.0); - ui.label(egui::RichText::new(tl!("Font Technology")).size(11.0).color(t.text_dim)); - if ui.selectable_label(st.variable_only, egui::RichText::new(variable_label()).size(11.0)).clicked() { - st.variable_only = !st.variable_only; - refilter = true; - } - }); + let refilter = filters(ui, st, &t); let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); let db = vectorcraft_text::FontDb::global(); // The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック), @@ -1064,6 +1057,149 @@ fn header<'s>(ui: &mut Ui, state_id: egui::Id, st: &'s mut MenuState, current: & Listed { st, rows, current: current.to_string(), enter, keyed, opening } } +/// One of the filters' chips. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Chip { + /// A script ([`SCRIPTS`]). + Script(usize), + Favorites, + AnyKind, + Class(FontClass), + /// The Font Technology label, which filters nothing. + Technology, + Variable, +} + +/// The filters, row by row, the same wherever the menu opens: the scripts; favorites, any kind +/// and the first three kinds; the other kinds; font technology. +fn filter_rows() -> [Vec<Chip>; 4] { + let (first, rest) = FontClass::ALL.split_at(3); + [ + (0..SCRIPTS).map(Chip::Script).collect(), + [Chip::Favorites, Chip::AnyKind].into_iter().chain(first.iter().copied().map(Chip::Class)).collect(), + rest.iter().copied().map(Chip::Class).collect(), + vec![Chip::Technology, Chip::Variable], + ] +} + +/// The filters on their rows ([`filter_rows`]): a row wraps only where the menu is narrower than +/// it. Records in `st` the width of the widest row unwrapped; whether a filter changed. +fn filters(ui: &mut Ui, st: &mut MenuState, t: &Tokens) -> bool { + let mut refilter = false; + let mut widest = 0.0_f32; + for row in filter_rows() { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 2.0); + let gap = ui.spacing().item_spacing.x; + let text = |s: &str| egui::RichText::new(s).size(FILTER_SIZE); + let mut width = -gap; + for chip in row { + let resp = match chip { + Chip::Script(i) => ui.selectable_label(st.script == i, text(script_label(i))), + Chip::Favorites => ui.selectable_label(st.favorites_only, text("★")).on_hover_text(tl!("Favorites")), + Chip::AnyKind => ui.selectable_label(st.class.is_none(), text(tl!("Any Kind"))), + Chip::Class(c) => ui.selectable_label(st.class == Some(c), text(class_label(c))), + Chip::Technology => ui.label(text(tl!("Font Technology")).color(t.text_dim)), + Chip::Variable => ui.selectable_label(st.variable_only, text(variable_label())), + }; + width += resp.rect.width() + gap; + if !resp.clicked() { + continue; + } + match chip { + Chip::Script(i) => st.script = i, + Chip::Favorites => st.favorites_only = !st.favorites_only, + Chip::AnyKind => st.class = None, + Chip::Class(c) => st.class = if st.class == Some(c) { None } else { Some(c) }, + Chip::Technology => continue, + Chip::Variable => st.variable_only = !st.variable_only, + } + refilter = true; + } + widest = widest.max(width); + }); + } + st.filters = widest; + refilter +} + +/// The width the open menu takes at least: the widest row of filters last frame, and the widest +/// name up to [`NAME_MAX`] with room for its sample, the star and the scroll bar beside it, within +/// [`WINDOW_SHARE`] of the window. The menu is never narrower than its field. +fn menu_width(ui: &Ui, st: &MenuState, look: MenuLook) -> f32 { + let sample = if look.samples { SAMPLE_W } else { 0.0 }; + let names = name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + let row = DISCLOSURE + names + NAME_GAP + sample + STAR + 2.0 + widgets::list_scroll_style().allocated_width(); + // Rounding in the chips' widths must not wrap the widest row. + let filters = if st.filters > 0.0 { st.filters.ceil() + 1.0 } else { 0.0 }; + filters.max(row).min(ui.ctx().content_rect().width() * WINDOW_SHARE) +} + +/// The width of the widest name the menu lists after the disclosure triangle: the family names as +/// Show Font Names in English shows them, and the style names among `rows` after their indent. +fn name_width(ui: &Ui, english: bool, rows: &[Row]) -> f32 { + let font = egui::FontId::proportional(NAME_SIZE); + let measure = |name: &str| ui.painter().layout_no_wrap(name.to_string(), font.clone(), Color32::WHITE).size().x; + let styles = rows.iter().filter_map(Row::style).map(|s| STYLE_INDENT + measure(s)).fold(0.0, f32::max); + family_name_width(ui, english).max(styles) +} + +/// What the widest family name was measured for: the font list's generation, Show Font Names in +/// English, and the number of fonts the interface draws with, which grows as faces for other +/// scripts are added to it. +fn family_name_key(ui: &Ui, english: bool) -> (u64, bool, usize) { + let faces = ui.ctx().fonts(|f| f.definitions().font_data.len()); + (vectorcraft_text::FontDb::global().generation(), english, faces) +} + +/// The id under which the widest family name's width is kept with its [`family_name_key`]. +fn family_name_id() -> egui::Id { + egui::Id::unique("font-menu-name-width") +} + +/// The width of the widest family name the menu lists, labeled as Show Font Names in English +/// (`english`) labels it; worked out again only when its [`family_name_key`] changes. +fn family_name_width(ui: &Ui, english: bool) -> f32 { + let key = family_name_key(ui, english); + if let Some((k, w)) = ui.data(|d| d.get_temp::<((u64, bool, usize), f32)>(family_name_id())) + && k == key + { + return w; + } + let db = vectorcraft_text::FontDb::global(); + let w = widest_label(ui, db.menu_family_list().iter().map(|f| db.family_display_name(f, english)).collect()); + ui.data_mut(|d| d.insert_temp(family_name_id(), (key, w))); + w +} + +/// The width of the widest of `labels` laid out at the names' size. Each label's characters' +/// advances are added up, and the labels are laid out widest sum first, until no sum left could +/// reach the widest layout, allowing [`SHAPING`] for shaping. +fn widest_label(ui: &Ui, labels: Vec<String>) -> f32 { + let font = egui::FontId::proportional(NAME_SIZE); + let mut sums: Vec<(f32, String)> = + ui.ctx().fonts_mut(|f| labels.into_iter().map(|l| (l.chars().map(|c| f.glyph_width(&font, c)).sum(), l)).collect()); + sums.sort_by(|a, b| b.0.total_cmp(&a.0)); + let mut w = 0.0_f32; + for (sum, label) in sums { + if sum * (1.0 + SHAPING) + 1.0 <= w { + break; + } + w = w.max(ui.painter().layout_no_wrap(label, font.clone(), Color32::WHITE).size().x); + } + w +} + +/// Where a row `width` wide puts its sample, after the name column: room for the widest name +/// (`widest`) and a gap, as far as that leaves [`SAMPLE_W`] and the star for the sample or +/// [`NAME_SHARE`] of the row for the name. Without samples the name runs to the star. +fn name_column(width: f32, widest: f32, samples: bool) -> f32 { + if !samples { + return width - STAR; + } + (DISCLOSURE + widest + NAME_GAP).min((width - STAR - 2.0 - SAMPLE_W).max(width * NAME_SHARE)) +} + /// The disclosure triangle of a family with several styles; whether the pointer is on it. fn disclosure(p: &egui::Painter, resp: &egui::Response, tri: egui::Rect, open: bool, t: &Tokens) -> bool { p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim); @@ -1101,6 +1237,7 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio let t = Tokens::get(ui.ctx()); let db = vectorcraft_text::FontDb::global(); let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f)); + let widest = name_width(ui, look.english_names, &rows); if rows.is_empty() { // A filter or a font rescan can remove the row whose font was previewed. // Do not leave that font applied while the list has no candidate to highlight. @@ -1166,8 +1303,8 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio starred = Some(f.clone()); } } - let indent = if row.style().is_some() { DISCLOSURE + 12.0 } else { DISCLOSURE }; - let name_w = rect.width() * NAME_SHARE; + let indent = if row.style().is_some() { DISCLOSURE + STYLE_INDENT } else { DISCLOSURE }; + let name_w = name_column(rect.width(), widest, look.samples); let name_rect = egui::Rect::from_min_max(rect.min + vec2(indent, 0.0), egui::pos2(rect.min.x + name_w - 4.0, rect.max.y)); // Family rows follow Show Font Names in English; style rows keep the style name (#394). let name = match row { @@ -1175,8 +1312,9 @@ fn rows(ui: &mut Ui, range: std::ops::Range<usize>, h: Listed<'_>, sample: Optio Row::Family(f) => db.family_display_name(f, look.english_names), Row::Heading(_) => continue, }; - ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text); - let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 2.0), rect.max - vec2(STAR + 2.0, 2.0)); + ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(NAME_SIZE), t.text); + // The sample takes the row's whole height. + let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 0.0), rect.max - vec2(STAR + 2.0, 0.0)); if !look.samples { } else if let Some(tex) = sample_texture(ui.ctx(), family, row.style(), sample, sample_rect.height()) { paint_sample(ui, sample_rect, &tex, t.text); @@ -1376,6 +1514,21 @@ pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Optio render_sample(family, "Regular", text, px) } +/// Waits for the samples rendering on this thread's behalf and keeps them, so the next frame +/// draws them. +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) fn wait_for_samples(ctx: &egui::Context) { + SAMPLES.with(|s| { + let mut s = s.borrow_mut(); + let pending: Vec<_> = s.pending.drain().collect(); + for (key, rx) in pending { + let tex = + rx.recv().ok().flatten().map(|img| ctx.load_texture(format!("font-sample-{}-{}", key.0, key.3), img, egui::TextureOptions::LINEAR)); + s.ready.insert(key, tex); + } + }); +} + // ---------- applying a pick ---------- /// Is a font previewed on the text (an interaction is open for it)? diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs index 259408711c..69063e8e87 100644 --- a/crates/ui-egui/src/font_menu/tests.rs +++ b/crates/ui-egui/src/font_menu/tests.rs @@ -58,6 +58,103 @@ fn the_scroll_bar_leaves_the_stars_clear() { } } +/// In a wide row the sample starts after the widest name and a gap, not halfway along, even for a +/// name wider than the menu widens itself for; the sample keeps its room, and a row too narrow for +/// both gives the name half of it. Without samples the name runs to the star. +#[test] +fn the_sample_starts_after_the_widest_name() { + use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR, name_column}; + assert_eq!(name_column(500.0, 100.0, true), DISCLOSURE + 100.0 + NAME_GAP); + assert_eq!(name_column(600.0, NAME_MAX + 80.0, true), DISCLOSURE + NAME_MAX + 80.0 + NAME_GAP); + assert_eq!(name_column(600.0, 1000.0, true), 600.0 - STAR - 2.0 - SAMPLE_W); + assert_eq!(name_column(200.0, 100.0, true), 100.0); + assert_eq!(name_column(500.0, 100.0, false), 500.0 - STAR); +} + +/// The widest label is the widest one laid out, though it has fewer characters than others, or +/// characters that are narrow on their own. +#[test] +fn the_widest_label_is_the_widest_laid_out() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let mut labels: Vec<String> = ["WWWWWWWW", "Mm", "", "ffi ffl fi fl", "Source Serif 4 Display Semibold"].map(str::to_string).into(); + labels.push("i".repeat(40)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let font = egui::FontId::proportional(super::NAME_SIZE); + let laid_out = |l: &String| ui.painter().layout_no_wrap(l.clone(), font.clone(), egui::Color32::WHITE).size().x; + let widest = labels.iter().map(laid_out).fold(0.0, f32::max); + assert_eq!(super::widest_label(ui, labels.clone()), widest); + for l in &labels { + assert_eq!(super::widest_label(ui, vec![l.clone()]), laid_out(l), "{l}"); + } + assert_eq!(super::widest_label(ui, vec![]), 0.0); + }); + out.textures_delta.clear(); +} + +/// The widest family name is measured again when the interface's fonts change, as they do when +/// faces for another script are added to it, not only when the font list does. +#[test] +fn the_widest_name_is_measured_again_when_the_interface_fonts_change() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |f: &mut dyn FnMut(&mut egui::Ui)| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| f(ui)); + out.textures_delta.clear(); + }; + // A width no name has, kept as if measured for the fonts as they are. + frame(&mut |ui| { + let key = super::family_name_key(ui, true); + ui.data_mut(|d| d.insert_temp(super::family_name_id(), (key, -1.0_f32))); + }); + frame(&mut |ui| { + let data = ui.ctx().fonts(|f| f.definitions().font_data.values().next().cloned()).expect("an interface font"); + ui.ctx().add_font(egui::epaint::text::FontInsert { name: "font-menu-test-face".into(), data: (*data).clone(), families: vec![] }); + }); + frame(&mut |ui| assert!(super::family_name_width(ui, true) >= 0.0, "measured again")); +} + +/// Without a row of filters drawn yet, the menu still makes room for the widest name, its sample, +/// the star and the scroll bar; without samples it is the sample's room narrower. +#[test] +fn the_menu_makes_room_for_the_widest_name_and_its_sample() { + use super::{DISCLOSURE, NAME_GAP, NAME_MAX, SAMPLE_W, STAR}; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + let st = super::MenuState::default(); + let look = super::MenuLook::default(); + let names = super::name_width(ui, look.english_names, &st.rows).min(NAME_MAX); + assert!(names > 0.0, "a family is listed"); + let width = super::menu_width(ui, &st, look); + assert!(width >= DISCLOSURE + names + NAME_GAP + SAMPLE_W + STAR, "the menu is {width} wide for a name {names} wide"); + let plain = super::menu_width(ui, &st, super::MenuLook { samples: false, ..look }); + assert!((width - plain - SAMPLE_W).abs() < 0.01, "{width} with samples, {plain} without"); + }); + out.textures_delta.clear(); +} + +/// The style names of the families expanded in the listed rows count toward the names' width; +/// those of a family expanded earlier but not listed now do not. +#[test] +fn only_listed_style_names_widen_the_names() { + use super::{Row, STYLE_INDENT}; + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let long = format!("SemiCondensed ExtraLight Italic {}", "W".repeat(80)); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + let families = super::name_width(ui, true, &[]); + let font = egui::FontId::proportional(super::NAME_SIZE); + let style = STYLE_INDENT + ui.painter().layout_no_wrap(long.clone(), font, egui::Color32::WHITE).size().x; + assert!(style > families, "the style name is wider than every family name"); + let listed = [Row::Family("A".into()), Row::Style("A".into(), long.clone())]; + assert_eq!(super::name_width(ui, true, &listed), style); + assert!(super::name_width(ui, true, &listed[..1]) < style, "a style not listed is not measured"); + }); + out.textures_delta.clear(); +} + use super::{Row, remap_highlight}; fn family(name: &str) -> Row { diff --git a/crates/ui-egui/src/tests/font_menu.rs b/crates/ui-egui/src/tests/font_menu.rs index ea79c1d5cc..109122308f 100644 --- a/crates/ui-egui/src/tests/font_menu.rs +++ b/crates/ui-egui/src/tests/font_menu.rs @@ -959,3 +959,142 @@ fn a_preview_stays_put_while_the_menu_is_still() { assert_eq!(picked, Some(FontPick::EndPreview)); assert_eq!(font(&app), "Inter"); } + +/// What an open font menu drew: each text with its rectangle and clip rectangle, the rectangles of +/// the samples, and the menu's rectangle. +struct Drawn { + texts: Vec<(String, egui::Rect, egui::Rect)>, + samples: Vec<egui::Rect>, + menu: egui::Rect, +} + +impl Drawn { + fn text(&self, label: &str) -> egui::Rect { + self.texts.iter().find(|(t, ..)| t == label).map(|(_, r, _)| *r).unwrap_or_else(|| panic!("{label} is drawn")) + } + + /// The filters' rows, each by the centers of its first and last chips, in the UI's language. + fn filter_rows(&self) -> Vec<(f32, f32)> { + use crate::i18n::{current, t, tr_ctx}; + let variable = tr_ctx(current(), "font-technology", "Variable"); + [(t("All Scripts"), t("Thai")), ("★", t("Rounded")), (t("Script / Brush"), t("Decorative")), (t("Font Technology"), variable)] + .map(|(first, last)| (self.text(first).center().y, self.text(last).center().y)) + .to_vec() + } + + /// The filters lie on their four rows, none wrapped. + fn assert_filter_rows(&self) { + let rows = self.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "filter row {i} wraps: its first chip at {first}, its last at {last}"); + } + assert!(rows.windows(2).all(|w| w[1].0 > w[0].0 + 4.0), "four rows of filters, in order: {rows:?}"); + } +} + +/// Opens the font menu of a field `width` wide whose left edge is `left` points into a window of +/// size `screen`, draws it until it settles and its samples are rendered, and returns what the +/// last frame drew. +fn open_menu(id: &str, left: f32, width: f32, screen: egui::Vec2) -> Drawn { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let field = egui::Rect::from_min_size(egui::pos2(left, 10.0), egui::vec2(width, 30.0)); + let frame = |events: Vec<egui::Event>| { + let input = egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + ui.scope_builder(egui::UiBuilder::new().max_rect(field), |ui| { + font_menu(ui, id, "Inter", width, None, MenuLook::default()); + }); + }); + out.textures_delta.clear(); + out.shapes + }; + let click = field.min + egui::vec2(10.0, 10.0); + let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(vec![]); + frame(vec![egui::Event::PointerMoved(click), button(true)]); + frame(vec![button(false)]); + for _ in 0..5 { + frame(vec![]); + } + crate::font_menu::wait_for_samples(&ctx); + let shapes = frame(vec![]); + let mut texts = vec![]; + let mut samples = vec![]; + for s in &shapes { + match &s.shape { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), egui::Rect::from_min_size(t.pos, t.galley.size()), s.clip_rect)), + egui::Shape::Mesh(m) if m.texture_id != egui::TextureId::default() => samples.push(m.calc_bounds()), + _ => {} + } + } + let mut drawn = Drawn { texts, samples, menu: egui::Rect::NOTHING }; + let filter = drawn.text(crate::i18n::t("Any Kind")).center(); + drawn.menu = ctx.layer_id_at(filter).and_then(|l| ctx.memory(|m| m.area_rect(l.id))).expect("the menu is open"); + drawn +} + +/// A narrow field, such as the Control bar's, opens a menu wide enough for the filters on their +/// rows and for the families' names, each drawn whole beside its sample. +#[test] +fn a_narrow_field_opens_a_menu_wide_enough_for_the_filters_and_names() { + let field = 115.0; + let d = open_menu("test-narrow", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + assert!(d.menu.width() > 2.0 * field, "the menu is {} wide", d.menu.width()); + let families = FontDb::global().menu_family_list(); + // The field shows the current family too: only the names in the menu's rows. + let names: Vec<_> = d.texts.iter().filter(|(t, r, _)| r.top() > d.text("Variable").bottom() && families.iter().any(|f| f == t)).collect(); + assert!(names.iter().any(|(_, r, _)| r.width() > field / 2.0), "a name wider than half the field is listed: {names:?}"); + // The menu widens itself for names up to its limit; a longer name is cut. + for (name, r, clip) in names.iter().filter(|(_, r, _)| r.width() <= crate::font_menu::NAME_MAX) { + assert!(r.right() <= clip.right() + 0.5, "{name} ends at {} and is cut at {}", r.right(), clip.right()); + } +} + +/// In every UI language the filters keep their four rows unwrapped when the window has room for +/// them: the longest translated labels set the menu's width. +#[test] +fn the_filter_rows_stay_whole_in_every_language() { + for lang in crate::i18n::Lang::all() { + crate::i18n::set_current(lang); + let d = open_menu(&format!("test-lang-{}", lang.code()), 20.0, 115.0, egui::vec2(2400.0, 900.0)); + let rows = d.filter_rows(); + for (i, (first, last)) in rows.iter().enumerate() { + assert!((first - last).abs() < 1.0, "{}: filter row {i} wraps in a menu {} wide", lang.code(), d.menu.width()); + } + } + crate::i18n::set_current(crate::i18n::Lang::EN); +} + +/// A menu opened near the window's right edge is wider than its field and stays in the window. +#[test] +fn a_menu_wider_than_its_field_stays_in_the_window() { + let screen = egui::vec2(700.0, 800.0); + let d = open_menu("test-edge", 600.0, 90.0, screen); + d.assert_filter_rows(); + assert!(d.menu.width() > 200.0, "the menu is {} wide", d.menu.width()); + assert!(d.menu.left() >= -0.5 && d.menu.right() <= screen.x + 0.5, "the menu at {:?} leaves the window", d.menu); +} + +/// A docked panel's field is as wide as the panel: its menu takes that width, with the filters on +/// the same rows as a narrow field's menu. +#[test] +fn a_wide_fields_menu_takes_its_width_with_the_same_filter_rows() { + let field = 480.0; + let d = open_menu("test-docked", 20.0, field, egui::vec2(1200.0, 800.0)); + d.assert_filter_rows(); + // The field is drawn 4 points narrower than its width; the menu's frame adds its margins. + assert!(d.menu.width() >= field - 4.0 && d.menu.width() < field + 40.0, "the menu is {} wide", d.menu.width()); +} + +/// Each sample takes its row's whole height. +#[test] +fn samples_fill_their_rows_height() { + let d = open_menu("test-samples", 20.0, 260.0, egui::vec2(1200.0, 800.0)); + assert!(!d.samples.is_empty(), "samples are drawn"); + let row = MenuLook::default().row; + for s in &d.samples { + assert!((s.height() - row).abs() < 0.5, "a sample {} high in a row {row} high", s.height()); + } +} diff --git a/docs/type-parity.md b/docs/type-parity.md index 30547d5a17..c67e915d56 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -31,7 +31,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Character size and horizontal scale after object/group scaling | done | #1034: fields and setters compensate the stored affine; point size follows the transformed em-height axis, not the glyph ink bounds. Range edits, keyboard increments, leading and baseline shift use document points; rotation, reflection and shear remain editable. | | Preserve font sizes while scaling a group | missing | Separate enhancement; scaling still scales text with the other artwork. | | OpenType panel, Glyphs panel, alternates | done | Highlight Alternate Glyphs preference (#394, off by default) paints pink behind OpenType substitutions on screen when on (required shaping such as Arabic joining forms and the lam-alef ligature, including a lam-alef older fonts file under `liga`, is not highlighted; an optional feature that changes the ligature is); stylistic sets ss01–ss20, stylistic and titling alternates, proportional and lining figures, superscript and subscript are character-style features (`text.setStyle {features}`, #1607); the OpenType panel's Position menu sets superscript, subscript, numerator or denominator glyphs (`sups`, `subs`, `numr`, `dnom`). The OpenType panel's Stylistic Sets menu lists the selected font's `ss01`–`ss20` features, using a font-supplied name where available and `Set N` otherwise; several sets can be active, and selecting an active one removes it. For a mixed-font selection it uses the first selected character's font, a VectorCraft design choice ([public help](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/add-stylistic-sets-to-selected-text.html), [OpenType feature specification](https://learn.microsoft.com/en-us/typography/opentype/spec/features_pt)). The Glyphs panel highlights the character selected with the Type tool (a range's first character, else the one before the caret) and scrolls it into view as the selection moves (#4908; `panels::glyphs` tests); its Show menu lists Unicode ranges and Alternates for Current Selection, the selected character's alternate glyphs from the font's `GSUB` table, one of which a double-click draws for that character (`text.setGlyphAlternate`); the glyph-cell pop-up of alternates is not there yet | -| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972). | +| Font menu: samples, live preview, filters, favourites, recent families; font matching by any name; Find Font | done | #3515: recently applied families appear first, once each; missing families are omitted and the existing Number of Recent Fonts setting applies; #339; script filter: Japanese, Latin, Thai; the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux); Select Text by Font takes a scope: the document, chosen artboards or chosen layers; Find Font's Find In offers Document, Artboard (the active one) and Layers (the selected ones, else the current one) (#5529, a VectorCraft addition); Replace With Font lists family and style rows, one click setting both, with a search over them; its list scrolls on its own, apart from the document's font list (#4972); wherever the font menu opens, its filters keep the same four rows, and it opens at least wide enough for them and for a family name up to 220 points wide with its sample (up to 60% of the window), each sample filling its row's height (a VectorCraft layout). | | Missing glyphs set in an installed font made for their script (Thai, Lao, Khmer, Myanmar first; then any font that has them) | done | Thai: Leelawadee UI, Tahoma, Thonburi, Sukhumvit Set, Noto Sans Thai, TLWG | | Full font-name matching (OpenType name ID 4) | done | #1258: loaded/cataloged faces and Missing Fonts searches; localized names and variable default faces; Windows font-service QA pending | | Variable fonts | done | named instances as styles; Character panel › Variable Font sets each axis (`fontAxes`); the font menu's Font Technology › Variable lists only variable font families, with the script, kind and ★ filters ([Find, apply, and adjust variable fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-apply-and-adjust-variable-fonts.html)); a family with a variable file beside static ones counts as variable. Evidence: `font_menu::tests::the_variable_filter_lists_only_variable_families`, `tests_axes::a_family_with_variation_axes_is_variable`, `tests_sysfonts::a_family_with_a_variable_file_beside_static_ones_is_variable` | From cc761c359ed233f21bddc5449b10e90ad3b1b45d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:35:11 -0800 Subject: [PATCH 27/35] Color: a recent color drags into the Swatches panel to become a swatch A Recent Colors chip in the Color or Swatches panel now starts a paint drag. Dropped on the Swatches panel's list it becomes a swatch of its color, in its color model, through swatch.new as one undo step: on a color group or one of its swatches it joins that group, elsewhere it joins the ungrouped swatches. A click on a chip still paints the selected art, and a drag does not. A paint Alt-dropped on Registration now becomes a new swatch instead of ending in a "can't be edited" error from swatch.edit. --- crates/ui-egui/src/panels.rs | 6 +- crates/ui-egui/src/panels/swatches/dnd.rs | 17 ++- crates/ui-egui/src/panels/swatches/tests.rs | 139 +++++++++++++++++++- crates/ui-egui/src/widgets/drag.rs | 19 +-- docs/target-app-parity.md | 1 + 5 files changed, 160 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 899f026398..cbad3dd34f 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -528,7 +528,8 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { /// "Recent Colors" header + rows of chips (the Session's recent colours, which every paint /// command feeds), wrapped onto as many rows as the panel's width needs; returns the one clicked, -/// for the caller to apply. +/// for the caller to apply. A chip drags its color as a solid paint, which the Swatches panel +/// makes a swatch of. pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Color> { let t = Tokens::get(ui.ctx()); crate::widgets::subheader(ui, tl!("Recent Colors")); @@ -543,7 +544,8 @@ pub(crate) fn recent_colors_row(app: &VectorcraftApp, ui: &mut Ui) -> Option<Col for (i, c) in colors.iter().enumerate() { let (row, col) = (i / per_row, i % per_row); let cell = Rect::from_min_size(r.min + vec2(3.0 + col as f32 * 19.0, 3.0 + row as f32 * 19.0), vec2(16.0, 16.0)); - let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click()); + let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click_and_drag()); + crate::widgets::drag_source(ui, &resp, || crate::widgets::PanelDrag::paint(Paint::solid(*c))); crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered()); if resp.on_hover_text(c.to_hex()).clicked() { chosen = Some(*c); diff --git a/crates/ui-egui/src/panels/swatches/dnd.rs b/crates/ui-egui/src/panels/swatches/dnd.rs index 22b542a3f0..d4360b4743 100644 --- a/crates/ui-egui/src/panels/swatches/dnd.rs +++ b/crates/ui-egui/src/panels/swatches/dnd.rs @@ -59,9 +59,10 @@ pub(super) enum Drop { /// `target` of its kind (neither None, Registration nor a tint swatch; see [`replaces`]), it /// gives that swatch its paint instead of moving. Move { names: Vec<String>, target: Option<String>, after: bool, with: Option<String> }, - /// A paint from elsewhere (a Fill/Stroke proxy, the Gradient panel's thumbnail) dropped on row - /// `target` (or between rows) becomes a swatch; with Alt held (`replace`) it replaces swatch - /// `target` when that is of its kind and not a tint swatch. + /// A paint from elsewhere (a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's + /// thumbnail) dropped on row `target` (or between rows) becomes a swatch; with Alt held + /// (`replace`) it replaces swatch `target` when that is of its kind and neither Registration + /// nor a tint swatch. New { paint: Paint, target: Option<String>, replace: bool }, /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). Pattern { ids: Vec<u64> }, @@ -259,7 +260,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { None => return, }, // Alt: an edited gradient (or colour) replaces the swatch it is dropped on. - Drop::New { paint, target: Some(t), replace: true } if d.swatch(&t).is_some_and(|w| w.tint_of().is_none() && same_kind(&w.paint, &paint)) => { + Drop::New { paint, target: Some(t), replace: true } + if d.swatch(&t).is_some_and(|w| !w.is_reserved() && w.tint_of().is_none() && same_kind(&w.paint, &paint)) => + { ("swatch.edit", json!({"name": t, "paint": crate::panels::swatch_paint_params(&paint)})) } Drop::New { paint, target, .. } => { @@ -279,9 +282,9 @@ pub(super) fn apply_drop(app: &mut VectorcraftApp, drop: Drop) { } /// While a panel drags something onto art, a chip follows the pointer: the dragged paint (swatches, -/// a Fill/Stroke proxy, the Gradient panel's thumbnail), the fill of the object whose appearance -/// the Appearance panel's thumbnail carries (or of the first art dragged off the canvas), or the -/// thumbnail of a dragged graphic style. +/// a Fill/Stroke proxy, a Recent Colors chip, the Gradient panel's thumbnail), the fill of the +/// object whose appearance the Appearance panel's thumbnail carries (or of the first art dragged +/// off the canvas), or the thumbnail of a dragged graphic style. pub(crate) fn drag_preview(app: &VectorcraftApp, ctx: &egui::Context) { let Some(d) = egui::DragAndDrop::payload::<PanelDrag>(ctx) else { return }; let fill_of = |id: &vectorcraft_doc::NodeId| app.session.active().and_then(|st| st.doc.node(*id)).map(|n| n.appearance.fill_paint()); diff --git a/crates/ui-egui/src/panels/swatches/tests.rs b/crates/ui-egui/src/panels/swatches/tests.rs index c649e282ba..3586f91607 100644 --- a/crates/ui-egui/src/panels/swatches/tests.rs +++ b/crates/ui-egui/src/panels/swatches/tests.rs @@ -437,11 +437,24 @@ fn drag(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, tim /// [`drag`] with `modifiers` held. fn drag_with(app: &mut VectorcraftApp, ctx: &egui::Context, from: Pos2, to: Pos2, modifiers: Modifiers, time: f64) { + drag_drawn(app, ctx, from, to, modifiers, time, show); +} + +/// [`drag_with`] over the panels `draw` draws. +fn drag_drawn( + app: &mut VectorcraftApp, + ctx: &egui::Context, + from: Pos2, + to: Pos2, + modifiers: Modifiers, + time: f64, + draw: fn(&mut VectorcraftApp, &mut Ui), +) { let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; - frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, show); - frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, show); - frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, show); - frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, show); + frame_with(app, ctx, vec![Event::PointerMoved(from), button(from, true)], modifiers, time, draw); + frame_with(app, ctx, vec![Event::PointerMoved(from + vec2(6.0, 4.0))], modifiers, time + 0.1, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to)], modifiers, time + 0.2, draw); + frame_with(app, ctx, vec![Event::PointerMoved(to), button(to, false)], modifiers, time + 0.3, draw); } fn names_of(app: &VectorcraftApp, group: Option<&str>) -> Vec<String> { @@ -553,6 +566,124 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none()); } +/// The center of the Recent Colors chip `i` drawn by `draw` in a frame at `time`: the chip of the +/// panel's row, or (`color`) of the Color panel's row, drawn in a child whose scope id `draw` keeps +/// in the panel state `"test-color-scope"`. +fn recent_chip(app: &mut VectorcraftApp, ctx: &egui::Context, i: usize, time: f64, draw: fn(&mut VectorcraftApp, &mut Ui), color: bool) -> Pos2 { + let mut chip = None; + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 700.0))); + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), screen_rect, ..Default::default() }, |ui| { + draw(app, ui); + let scope = if color { pstate::<Option<egui::Id>>(ctx, "test-color-scope").expect("the Color panel is drawn") } else { ui.scope_id() }; + chip = ctx.read_response(scope.with(("recent", i))).map(|r| r.rect.center()); + }); + out.textures_delta.clear(); + chip.unwrap_or_else(|| panic!("recent chip {i} is drawn")) +} + +/// A rectangle filled with a CMYK color and then an RGB one, so Recent Colors holds the RGB color +/// first and the CMYK one second; → its id and the CMYK color. +fn art_with_two_recent_colors(app: &mut VectorcraftApp) -> (vectorcraft_doc::NodeId, Color) { + let id = app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": {"c": 10, "m": 60, "y": 20, "k": 5}, "keepModel": true})).unwrap(); + app.run("paint.setFill", json!({"ids": [id], "color": "#2050a0"})).unwrap(); + let cmyk = app.session.recent_colors[1]; + assert!(matches!(cmyk, Color::Cmyk { .. }), "the second recent color is CMYK: {cmyk:?}"); + (vectorcraft_doc::NodeId(id), cmyk) +} + +/// The fill of art `id`. +fn fill_of(app: &VectorcraftApp, id: vectorcraft_doc::NodeId) -> Option<Paint> { + app.session.doc().unwrap().doc.node(id).map(|n| n.appearance.fill_paint()) +} + +/// A Recent Colors chip dragged onto the swatch list becomes a swatch of its color, in its color +/// model, as one undo step; the drag paints no art, and a color that is already a swatch is added +/// again under another name. +#[test] +fn a_recent_color_dragged_onto_the_list_becomes_a_swatch() { + let mut app = app(); + let ctx = context(); + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let art = fill_of(&app, id); + let undo = app.session.doc().unwrap().history.undo.len(); + let (count, brights) = (app.session.doc().unwrap().doc.swatches_iter().count(), names_of(&app, Some("Brights")).len()); + // Onto a grouped swatch: into that group, at its end. + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Bright Red"), 1.0); + let d = app.session.doc().unwrap(); + let group = &d.doc.swatch_groups.iter().find(|g| g.name == "Brights").unwrap().swatches; + assert_eq!(group.len(), brights + 1, "{}", app.ui.status); + let first = group.last().cloned().expect("a swatch"); + assert_eq!(first.paint, Paint::solid(cmyk), "the chip's color, still CMYK"); + assert_eq!(d.history.undo.len(), undo + 1, "one undo step"); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + assert!(egui::DragAndDrop::payload::<PanelDrag>(&ctx).is_none()); + // The same color again, onto an ungrouped swatch: a second swatch under another name. + frame(&mut app, &ctx, vec![], 2.0, show); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, show, false); + drag(&mut app, &ctx, chip, tile_center(&ctx, "Red"), 3.0); + let newest = app.session.doc().unwrap().doc.swatches.last().cloned().expect("a swatch"); + assert_eq!(newest.paint, Paint::solid(cmyk)); + assert_ne!(newest.name, first.name, "another name"); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 2); + // Undo takes the last one off. + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc.swatches_iter().count(), count + 1); +} + +/// A color Alt-dropped on Registration, which can't be edited, becomes a new swatch and leaves +/// Registration as it is. +#[test] +fn a_color_alt_dropped_on_registration_becomes_a_new_swatch() { + let mut app = app(); + let ctx = context(); + let (_, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, show); + let registration = app.session.doc().unwrap().doc.swatch(REGISTRATION).map(|w| w.paint.clone()); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, show, false); + drag_with(&mut app, &ctx, chip, tile_center(&ctx, REGISTRATION), Modifiers::ALT, 1.0); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(d.swatch(REGISTRATION).map(|w| w.paint.clone()), registration, "Registration is unchanged"); +} + +/// A Recent Colors chip of the Color panel dragged onto the Swatches panel becomes a swatch; a click +/// on it still paints the selected art. +#[test] +fn a_recent_color_dragged_from_the_color_panel_becomes_a_swatch() { + fn both(app: &mut VectorcraftApp, ui: &mut Ui) { + show(app, ui); + ui.push_id("color", |ui| { + crate::panels::color::show(app, ui); + set_pstate(ui.ctx(), "test-color-scope", Some(ui.scope_id())); + }); + } + let mut app = app(); + let ctx = context(); + app.ui.color_show_recent_colors = true; + let (id, cmyk) = art_with_two_recent_colors(&mut app); + frame(&mut app, &ctx, vec![], 0.0, both); + let count = app.session.doc().unwrap().doc.swatches_iter().count(); + let art = fill_of(&app, id); + let chip = recent_chip(&mut app, &ctx, 1, 0.5, both, true); + drag_drawn(&mut app, &ctx, chip, tile_center(&ctx, "Red"), Modifiers::NONE, 1.0, both); + let d = &app.session.doc().unwrap().doc; + assert_eq!(d.swatches_iter().count(), count + 1, "{}", app.ui.status); + assert_eq!(d.swatches.last().map(|w| w.paint.clone()), Some(Paint::solid(cmyk))); + assert_eq!(fill_of(&app, id), art, "the drag paints no art"); + // A click on the chip paints the selected art. + app.run("select.set", json!({"ids": [id.0]})).unwrap(); + frame(&mut app, &ctx, vec![], 2.0, both); + let chip = recent_chip(&mut app, &ctx, 1, 2.5, both, true); + let button = |pressed| Event::PointerButton { pos: chip, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + frame(&mut app, &ctx, vec![Event::PointerMoved(chip), button(true), button(false)], 3.0, both); + assert_eq!(fill_of(&app, id).and_then(|f| f.color()), Some(cmyk), "a click paints the art"); +} + #[test] fn alt_dropping_a_gradient_on_a_gradient_swatch_replaces_it() { let mut app = app(); diff --git a/crates/ui-egui/src/widgets/drag.rs b/crates/ui-egui/src/widgets/drag.rs index 1564025a41..f130795c0f 100644 --- a/crates/ui-egui/src/widgets/drag.rs +++ b/crates/ui-egui/src/widgets/drag.rs @@ -4,12 +4,13 @@ use super::{Paint, Rect, Response, Stroke, StrokeKind, Tokens, Ui, Vec2}; /// handler for all of them (`canvas::panel_drop`); a chip of a dragged paint follows the pointer. #[derive(Clone, Debug, PartialEq)] pub enum PanelDrag { - /// A paint: Swatches panel tiles, a Fill/Stroke proxy or the Gradient panel's thumbnail. Art it - /// is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; null for a colour group, - /// which paints nothing) through the active proxy's `paint.setFill`/`paint.setStroke`; the - /// Gradient panel's ramp takes its colour as a stop; the Swatches panel moves `rows`, or makes - /// a swatch of a paint dragged from elsewhere. `library`: the Libraries panel colour it is - /// (library id, colour name), which that panel moves to where it is dropped. + /// A paint: Swatches panel tiles, a Fill/Stroke proxy, a Recent Colors chip or the Gradient + /// panel's thumbnail. Art it is dropped on takes `params` (`{swatch}`, `{color}`, `{gradient}`…; + /// null for a colour group, which paints nothing) through the active proxy's + /// `paint.setFill`/`paint.setStroke`; the Gradient panel's ramp takes its colour as a stop; the + /// Swatches panel moves `rows`, or makes a swatch of a paint dragged from elsewhere. `library`: + /// the Libraries panel colour it is (library id, colour name), which that panel moves to where + /// it is dropped. Paint { paint: Paint, params: serde_json::Value, rows: Option<SwatchRows>, library: Option<(String, String)> }, /// The Appearance panel's thumbnail: art it is dropped on takes object `0`'s appearance /// (`appearance.copyFrom`); the Graphic Styles panel makes a style of it. @@ -68,9 +69,9 @@ pub struct SwatchRows { } impl PanelDrag { - /// `paint` dragged from a Fill/Stroke proxy or the Gradient panel's thumbnail. A gradient - /// carries no placement: it fits the art it lands on (freeform points are placed afresh there, - /// coloured like the dragged ones). + /// `paint` dragged from a Fill/Stroke proxy, a Recent Colors chip or the Gradient panel's + /// thumbnail. A gradient carries no placement: it fits the art it lands on (freeform points are + /// placed afresh there, coloured like the dragged ones). pub fn paint(mut paint: Paint) -> Self { if let Paint::Gradient(g) = &mut paint { g.geom = None; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b172aefd7e..9ab066adca 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1324,6 +1324,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Plain Comma and Period, and also Shift+Comma and Shift+Period, apply the last solid color and the last gradient to the active Fill or Stroke proxy in VectorCraft ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), "Select color fill mode" and "Select gradient fill mode"). Recent Colors keeps the last 30 solid colors applied, newest first and each once, and the list stays the same when another document is opened or made active; a 31st color drops the oldest ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). The list lasts for the session. In the Color and Swatches panels the chips wrap onto further rows when the panel is too narrow for one row (`paint.recent`). Tests: `recent_colors_keep_thirty_colors_across_documents` in `crates/engine/src/tests/paintproxy.rs`, `all_thirty_recent_colors_wrap_into_a_narrow_panel` in `crates/ui-egui/src/panels/color/tests.rs`. Unchecking Show Recent Colors in the Color panel's menu hides its Recent Colors row, and checking it shows the row again ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft the item is checked by default and kept between sessions, applied colors still join the list while the row is hidden, Hide Options hides the row either way, and the Swatches panel keeps its row (`ui.colorPanel {recentColors}`). Tests: `show_recent_colors_hides_and_shows_the_row` in `crates/ui-egui/src/panels/color/tests.rs`. + A Recent Colors chip in the Color or Swatches panel drags into the Swatches panel's list to become a swatch of its color, in its color model, as one undo step ([Select colors using the Color panel](https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/select-colors-using-the-color-panel.html)). In VectorCraft a chip dropped on a color group or one of its swatches joins that group, anywhere else in the list it joins the ungrouped swatches at the end, a color that is already a swatch is added again under another name, and dragging a chip does not paint the selected art (`swatch.new`). Tests: `a_recent_color_dragged_onto_the_list_becomes_a_swatch` and `a_recent_color_dragged_from_the_color_panel_becomes_a_swatch` in `crates/ui-egui/src/panels/swatches/tests.rs`. A Cmd/Ctrl-click on the Color panel's spectrum applies Complement and a Cmd/Ctrl+Shift-click applies Invert to the active Fill or Stroke (`paint.complement`, `paint.invert`); with Alt also held, the click recolors the inactive one, as the reference app's [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html) list them. View › Proof Setup › Color blindness – Tritanopia-type turns Proof Colors on and proofs the canvas with the tritanopia simulation of Brettel, Viénot and Mollon (1997). The Separations Preview panel's Proof Setup list offers the same target (`view.proofSetup {target: "tritanopia"}`). Grays keep their value. The Separations Preview panel's Gamut Check button reports one color outside the CMYK gamut in the singular and more in the plural, in English and in each language's plural forms (`gamut_check_status_takes_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`). From 66f6be77eed52f4d5d066539fc5f8b1905e94812 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:05:32 -0800 Subject: [PATCH 28/35] .ai save: RGB and named colors keep their CMYK equivalents when a reopened file is saved The .ai writers write RGB and Lab values to four decimals next to a CMYK equivalent, but converted that equivalent from the color at full precision. When a file was read back and saved again, its CMYK equivalents differed in the fourth decimal (for example 0.6831 and 0.6832). In the editing data this affected plain RGB colors, RGB gradient stops, global process and spot colors and the header's custom colors; in the legacy .ai writer it affected RGB gradient stops. The CMYK equivalent of an RGB color, and of a global process or spot color, is now converted from the values as they are written, so both saves convert the same values. Lab values of global process and spot colors are now written clamped to the range the reader reads (L 0 to 100, a and b -128 to 127). A plain Lab fill or stroke and a Lab gradient stop are still written as RGB with a CMYK equivalent converted from the Lab values. The reopened file holds them as RGB, so their CMYK equivalents change once on its next save. The art style round-trip test now compares the program exactly; its blanked CMYK values and the four-decimal tolerance are no longer needed. --- crates/eps/src/ai_editing/tests.rs | 84 ++++++++++++---------- crates/eps/src/ai_editing/writer.rs | 19 +++-- crates/eps/src/ai_editing/writer/paints.rs | 11 +-- crates/eps/src/ai_ops.rs | 12 ++++ crates/eps/src/legacy_ai/tests.rs | 22 ++++++ crates/eps/src/legacy_ai/writer.rs | 3 +- crates/eps/src/ps.rs | 5 ++ 7 files changed, 106 insertions(+), 50 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 6baf4e802b..a486feb039 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -294,6 +294,51 @@ fn writing_again_gives_the_same_bytes() { assert!(first.warnings.is_empty(), "{:?}", first.warnings); } +/// The CMYK equivalent written beside RGB or Lab values is the one of the values as written (to four +/// decimals, Lab clamped to the range a reader reads), so the reopened file is written with the same +/// numbers: a new document's swatches, and colors of many decimals in fills, gradient stops, global +/// process swatches (RGB and Lab), an RGB spot swatch (in a fill and in a stop), Lab spot swatches +/// and a Lab spot swatch out of range. +#[test] +fn cmyk_equivalents_are_written_again_unchanged() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let rgb = |i: u16| Color::rgb(v(i, 0.137_911_3), v(i, 0.271_828_2), v(i, 0.314_159_3)); + let lab = |i: u16| Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0); + let glaze = rgb(102); + let swatch = |name: String, c: Color, spot: bool| Swatch { name, paint: solid(c), global: true, spot }; + let mut named: Vec<(String, Color, bool)> = + vec![("Glaze".into(), glaze, true), ("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65), true)]; + for i in 0..8u16 { + named.push((format!("Leaf {i}"), rgb(i + 110), false)); + named.push((format!("Fern {i}"), lab(i + 120), false)); + named.push((format!("Moss {i}"), lab(i + 130), true)); + } + let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect(); + swatches.extend(named.iter().map(|(name, c, spot)| swatch(name.clone(), *c, *spot))); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for i in 0..24u16 { + let x = f64::from(i % 8) * 30.0 + 10.0; + let y = f64::from(i / 8) * 30.0 + 10.0; + let n = square(&mut doc, Rect::new(x, y, x + 20.0, y + 20.0), rgb(i)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let mut stops: Vec<GradientStop> = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect(); + stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }); + let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)]; + fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 }))); + for (i, fill) in fills.into_iter().enumerate() { + let x = (i % 9) as f64 * 30.0 + 10.0; + let y = (i / 9) as f64 * 30.0 + 110.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, y, x + 20.0, y + 20.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let first = program(&doc); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>()); +} + #[test] fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { let doc = sample(); @@ -5870,25 +5915,6 @@ fn outlines(doc: &Document) -> Vec<String> { out } -/// Fails with the lines where `got` and `want` differ in more than their numbers' last written -/// decimal (a color read back from four decimals gives its other model's values again from them). -fn near_lines(got: &str, want: &str) { - let near = |a: &str, b: &str| { - let (a, b): (Vec<&str>, Vec<&str>) = (a.split(' ').collect(), b.split(' ').collect()); - a.len() == b.len() - && a.iter().zip(&b).all(|(x, y)| match (x.trim_start_matches("%_").parse::<f64>(), y.trim_start_matches("%_").parse::<f64>()) { - (Ok(x), Ok(y)) => (x - y).abs() <= 2e-3, - _ => x == y, - }) - }; - let (g, w): (Vec<&str>, Vec<&str>) = (got.lines().collect(), want.lines().collect()); - let differ: Vec<String> = (0..g.len().max(w.len())) - .filter(|&i| !matches!((g.get(i), w.get(i)), (Some(a), Some(b)) if near(a, b))) - .map(|i| format!("line {i}:\n got {:?}\n want {:?}", g.get(i), w.get(i))) - .collect(); - assert!(differ.is_empty(), "{}", differ.join("\n")); -} - #[test] fn objects_with_live_effects_and_graphic_styles_are_read_back() { let doc = styles_document(); @@ -6210,25 +6236,7 @@ fn art_styles_write_the_same_bytes_again() { assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); // What is read back is written as it was. let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; - near_lines(&rgb_without_cmyk(&shown(&again)), &rgb_without_cmyk(&shown(&first.program))); -} - -/// `program` with the CMYK equivalents of plain RGB colors blanked out: the first four numbers of -/// `c m y k r g b Xa` (or `XA`) and of an RGB gradient stop's `c m y k r g b 2 ... Bs`. Named and -/// spot RGB colors (`... (name) tint Xk`, `Xx` and spot stops) are not blanked; the document this -/// is used on has none. The Generic CMYK separation of a color at the edge of the press gamut can -/// land on one of two results that differ by up to 0.01 when the RGB changes in its last float -/// bits, as it does between the first save and the save of the reopened file, and the platform's -/// float math decides which. The RGB values stay compared. -fn rgb_without_cmyk(program: &str) -> String { - let blank = |line: &str| { - let (prefix, rest) = line.strip_prefix("%_").map_or(("", line), |r| ("%_", r)); - let t: Vec<&str> = rest.split(' ').collect(); - let color = t.len() == 8 && matches!(t[7], "Xa" | "XA"); - let stop = t.len() > 8 && t[7] == "2" && t.last() == Some(&"Bs"); - if color || stop { format!("{prefix}_ _ _ _ {}", t[4..].join(" ")) } else { line.to_string() } - }; - program.lines().map(blank).collect::<Vec<_>>().join("\n") + same_lines(&shown(&again).lines().map(String::from).collect::<Vec<_>>(), &shown(&first.program).lines().map(String::from).collect::<Vec<_>>()); } /// `n` on the first layer of `doc`. diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 35a47cc437..d5eeaeb08c 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1344,7 +1344,8 @@ impl<'a> Writer<'a> { } /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and - /// Lab as RGB), with its CMYK equivalent. + /// Lab as RGB), with its CMYK equivalent (an RGB color's from its values as written, a Lab + /// color's from its Lab values). fn plain_color(&mut self, color: Color, stroke: bool) { let s = &mut self.body; let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; @@ -1359,7 +1360,8 @@ impl<'a> Writer<'a> { let _ = writeln!(s, "{}", op("k", "K")); } Color::Rgb { .. } | Color::Lab { .. } => { - let cmyk = self.cms.to_cmyk(&color, self.cms.settings().intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); let _ = writeln!(s, "{}", op("Xa", "XA")); @@ -1368,10 +1370,12 @@ impl<'a> Writer<'a> { } /// A spot (`spot`) or global process color `name` of full color `full` at `tint`, written to - /// `s`: its CMYK equivalent, its RGB or Lab values, the name, how much lighter than the full - /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before - /// `Xx` (spot) or `Xk` (global process). + /// `s`: its CMYK equivalent (from its RGB or Lab values as written), its RGB or Lab values + /// (clamped as [`ai_ops::as_written`] clamps them), the name, how much lighter than the full + /// color, and for any but a CMYK spot color (`x`) the model (0 CMYK, 1 RGB, 2 Lab) before `Xx` + /// (spot) or `Xk` (global process). fn named(&self, s: &mut String, name: &str, full: Color, tint: f32, spot: bool, stroke: bool) { + let full = ai_ops::as_written(full); let cmyk = self.cms.to_cmyk(&self.doc.linked_color(full, spot), self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let model = match full { @@ -1467,7 +1471,8 @@ impl<'a> Writer<'a> { true } - /// The spot color swatches, with their full colors' CMYK equivalents, for the header. + /// The spot color swatches, with their full colors' CMYK equivalents (as the palette writes + /// them), for the header. pub(super) fn spot_swatches(&self) -> Vec<(String, [f32; 4])> { let intent = self.cms.settings().intent; self.doc @@ -1475,7 +1480,7 @@ impl<'a> Writer<'a> { .filter(|sw| sw.spot) .filter_map(|sw| match sw.paint { Paint::Solid { color, swatch: None, .. } => { - Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(color, true), intent).map(unit))) + Some((sw.name.clone(), self.cms.to_cmyk(&self.doc.linked_color(ai_ops::as_written(color), true), intent).map(unit))) } _ => None, }) diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index f370167bcf..81857a9501 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -11,7 +11,7 @@ use vectorcraft_effects::stroke; use vectorcraft_geom::{Affine, BezPath, Rect, Shape, Vec2}; use super::{MAX_NEST, Writer, Xy}; -use crate::ai_ops::{average, unit}; +use crate::ai_ops::{self, average, unit}; use crate::ps; pub(in crate::ai_editing) const GRADIENTS: &str = @@ -371,7 +371,9 @@ impl Writer<'_> { /// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB) /// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent, /// then its RGB values when it isn't CMYK, its name and how much lighter than the full color, - /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). + /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). An RGB color's CMYK equivalent is converted + /// from its values as written ([`ai_ops::as_written`]), a Lab color's from its Lab values, and a + /// spot color's is the one the palette writes for it. fn stop_color(&mut self, s: &mut String, stop: &GradientStop) { let doc = self.doc; let intent = self.cms.settings().intent; @@ -380,7 +382,7 @@ impl Writer<'_> { && let Paint::Solid { color: full, swatch: None, .. } = sw.paint { if sw.spot { - let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit); + let cmyk = self.cms.to_cmyk(&doc.linked_color(ai_ops::as_written(full), true), intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let rgb = !matches!(full, Color::Cmyk { .. }); if rgb { @@ -406,7 +408,8 @@ impl Writer<'_> { s.push_str("1 "); } color @ (Color::Rgb { .. } | Color::Lab { .. }) => { - let cmyk = self.cms.to_cmyk(&color, intent).map(unit); + let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; + let cmyk = self.cms.to_cmyk(&source, intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); s.push_str("2 "); diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 7707f4bc45..14b19684f9 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -103,6 +103,18 @@ pub(crate) fn unit(v: f32) -> f32 { if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } } +/// `color` as a reader reads it back from the values written for it ([`ps::written`]): RGB values +/// clamped to 0..1, L to 0..100 and a and b to -128..127, each to four decimals; other colors +/// unchanged. +pub(crate) fn as_written(color: Color) -> Color { + let w = |v: f32| ps::written(f64::from(v)) as f32; + match color { + Color::Rgb { r, g, b } => Color::rgb(w(unit(r)), w(unit(g)), w(unit(b))), + Color::Lab { l, a, b } => Color::lab(w(l.clamp(0.0, 100.0)), w(a.clamp(-128.0, 127.0)), w(b.clamp(-128.0, 127.0))), + c => c, + } +} + /// The average colour of a gradient's stops (in RGB). pub(crate) fn average(g: &Gradient) -> Color { let n = g.stops.len().max(1) as f32; diff --git a/crates/eps/src/legacy_ai/tests.rs b/crates/eps/src/legacy_ai/tests.rs index cfb7af8186..5f1262b8b8 100644 --- a/crates/eps/src/legacy_ai/tests.rs +++ b/crates/eps/src/legacy_ai/tests.rs @@ -755,6 +755,28 @@ fn a_gradient_fill_and_its_stroke_are_one_object() { assert_eq!(n.appearance.stroke_paint().color(), Some(Color::rgb(1.0, 0.0, 0.0))); } +/// An RGB gradient stop's CMYK equivalent is the one of its RGB values as written (to four +/// decimals), so the reopened file writes the same stops. +#[test] +fn rgb_stops_are_written_again_unchanged() { + let mut doc = Document::new(100.0, 100.0); + let rgb = |i: u16| { + let v = |s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + Color::rgb(v(0.137_911_3), v(0.271_828_2), v(0.314_159_3)) + }; + let stops = (0..12u16).map(|i| GradientStop::new(f32::from(i) / 11.0, rgb(i))).collect(); + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let n = shape(&mut doc, shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 50.0)), paint, Paint::None); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = |bytes: Vec<u8>| String::from_utf8(bytes).unwrap().lines().filter(|l| l.ends_with("%_Bs")).map(String::from).collect::<Vec<_>>(); + let first = write(&doc).bytes; + let again = write(&crate::import(&first).unwrap().document).bytes; + let (first, again) = (stops(first), stops(again)); + assert_eq!(first.len(), 12, "{first:?}"); + assert_eq!(again, first); +} + /// A gradient from white to #1b2a6b, the colors of #2687's circle, placed at `geom` (None: fitted /// to the object). fn white_to_navy(kind: GradientKind, geom: Option<vectorcraft_color::GradientGeom>) -> Paint { diff --git a/crates/eps/src/legacy_ai/writer.rs b/crates/eps/src/legacy_ai/writer.rs index 19d3c59bb1..bbe7b0115b 100644 --- a/crates/eps/src/legacy_ai/writer.rs +++ b/crates/eps/src/legacy_ai/writer.rs @@ -633,7 +633,8 @@ impl<'a> Writer<'a> { // Grey is ink coverage; the format's grey is lightness. Color::Gray { k } => Ink::Gray(unit(1.0 - k)), Color::Cmyk { c, m, y, k } => Ink::Cmyk([c, m, y, k].map(unit)), - Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), [r, g, b].map(unit)), + // The CMYK equivalent of the RGB values as written. + Color::Rgb { r, g, b } => Ink::Rgb(self.cms.to_cmyk(&ai_ops::as_written(*color), intent).map(unit), [r, g, b].map(unit)), Color::Lab { .. } => { let rgb = color.to_rgb().map(unit); Ink::Rgb(self.cms.to_cmyk(color, intent).map(unit), rgb) diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index 8fef6d75dd..88da08551e 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -36,6 +36,11 @@ pub(crate) fn push_num(s: &mut String, v: f64) { push_num_places(s, v, 4); } +/// The value [`num`]`(v)` stands for, as a reader parses it back. +pub(crate) fn written(v: f64) -> f64 { + num(v).parse().unwrap_or(0.0) +} + /// Append `v` to `s` with at most `places` decimals (up to 9), no trailing zeros (0 when not /// finite). pub(crate) fn push_num_places(s: &mut String, v: f64, places: u32) { From 89e52eaabc3c44b11ccc96ee17f815807662032b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 08:50:46 -0800 Subject: [PATCH 29/35] MCP: a folder preference is checked as trimmed, and only a %XX escape is decoded Under automation read roots, `ui.inspect` kept a folder preference whose stored value was readable even when the trimmed path the app uses was not: ` /outside/fonts` reads as a name in the working folder, while the app uses `/outside/fonts`. A folder preference now stays only when both forms are readable. A reply string is decoded for path matching only when it holds a `%XX` escape, so a string such as `width="100%"` is no longer decoded and thrown away. The library list line in the progress doc now states when a hidden library's `loaded/<file name>` id moves. --- crates/ui-egui/src/control.rs | 15 +++++++++++---- crates/ui-egui/src/control/tests.rs | 15 +++++++++++++++ crates/ui-egui/src/tests/automation.rs | 25 +++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f8022a3593..f568f5446c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -648,7 +648,7 @@ fn occurrences(text: &str, needle: &str, fold: bool) -> Vec<usize> { /// in letter case keep their own ids, and then the ones written in another case. `None` when nothing /// changed. fn replace_text(s: &str, r: &Rewrite) -> Option<String> { - let decoded = if s.contains('%') { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; + let decoded = if has_percent_escape(s) { decoded_with_spans(s).filter(|(decoded, _)| decoded != s) } else { None }; let mut found = Found::default(); for group in &r.pairs { for fold in [false, true].into_iter().take(if r.fold { 2 } else { 1 }) { @@ -774,6 +774,12 @@ fn decoded_with_spans(text: &str) -> Option<(String, Vec<(usize, usize)>)> { Some((decoded, decoded_spans)) } +/// Does `text` hold a `%XX` escape, one that [`decoded_with_spans`] decodes? Only such an escape +/// changes the decoded text. +fn has_percent_escape(text: &str) -> bool { + text.as_bytes().windows(3).any(|w| w[0] == b'%' && w[1].is_ascii_hexdigit() && w[2].is_ascii_hexdigit()) +} + /// Is `from`, a relative path (one that does not start with a separator, a drive or `loaded/`), at /// `text[at..]` the end of a longer path: after any `.` and `..` parts just before it, is it preceded by /// a letter, a digit, `_`, `-`, `.`, `~` or a separator? `/work/data/icon.svg` and @@ -1060,13 +1066,14 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) { } } -/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read. An -/// empty value (no folder) and a scratch disk choice ([`SCRATCH_CHOICES`]) stay. +/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read, as written +/// or trimmed as the app uses it. An empty value (no folder) and a scratch disk choice +/// ([`SCRATCH_CHOICES`]) stay. fn leave_out_unreadable_folders(prefs: &mut serde_json::Map<String, Value>) { for key in PREF_FOLDERS { let Some(written) = prefs.get(key).and_then(Value::as_str) else { continue }; let path = written.trim(); - if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || readable(written) { + if path.is_empty() || (key.starts_with("scratch") && SCRATCH_CHOICES.contains(&path)) || (readable(written) && readable(path)) { continue; } prefs.remove(key); diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index babc710067..6ae364e172 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -131,6 +131,21 @@ fn a_reply_hides_a_path_in_any_case_and_escaped() { assert_eq!(reply["error"], "c.svg, c.svg, c.svg", "{reply}"); } +/// Only a string with a `%XX` escape is decoded before it is searched: a `%` that begins no escape +/// leaves the text as it is, and the reply is the same either way. +#[test] +fn only_a_percent_escape_is_decoded() { + for (text, escaped) in [("width=\"100%\"", false), ("50% off", false), ("%zz%4", false), ("%", false), ("a%2Fb", true), ("%fF", true)] { + assert_eq!(super::has_percent_escape(text), escaped, "{text}"); + let decoded = super::decoded_with_spans(text).map(|(decoded, _)| decoded); + assert_eq!(decoded.as_deref() != Some(text), escaped, "{text}: an escape alone changes the decoded text"); + } + let hidden = hiding(&[("loaded//libs/b.svg", "loaded/b.svg")], &[]); + let mut reply = json!({"error": "100% of /libs/b.svg and file:///libs%2Fb.svg"}); + super::replace_in_value(&mut reply, &super::Rewrite { pairs: super::hidden_text(&hidden), hiding: &hidden, fold: false }); + assert_eq!(reply["error"], "100% of b.svg and b.svg", "{reply}"); +} + /// A path a failed request sent takes the place of a hidden library's id for the same path, written /// with the other separator, and in another letter case when matching in any case (as on Windows). #[test] diff --git a/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 0ed949edf5..43072d4a6e 100644 --- a/crates/ui-egui/src/tests/automation.rs +++ b/crates/ui-egui/src/tests/automation.rs @@ -499,6 +499,31 @@ fn ui_inspect_checks_a_folder_preference_as_written() { let _ = std::fs::remove_dir_all(base); } +/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working +/// folder as the read root, a path outside it with a space before it reads as a name in the working +/// folder, and is still left out, because the app trims it and uses the path outside. +#[test] +fn ui_inspect_checks_a_folder_preference_as_trimmed() { + let (_, _, base) = confined("inspect-folder-trimmed"); + let cwd = std::env::current_dir().unwrap(); + let roots = AutomationRoots::new(&[&cwd], &[&base.join("out")]).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = VectorcraftApp::new(Session::new(), Services::default()).with_control(rx).with_automation_roots(roots); + let outside = s(base.join("outside")); + // Without its drive, the path starts with a separator: trimmed, it is absolute on any platform. + let rooted = match outside.as_bytes() { + [drive, b':', ..] if drive.is_ascii_alphabetic() => &outside[2..], + _ => outside.as_str(), + }; + let spaced = format!(" {rooted}"); + app.ui.engine_prefs = json!({"fontsFolder": spaced, "pluginsFolder": ""}); + let r = request(&mut app, &tx, "ui.inspect", json!({})); + let prefs = r["result"]["ui"]["engine_prefs"].as_object().unwrap(); + assert_eq!(prefs.get("pluginsFolder").and_then(Value::as_str), Some(""), "{r}"); + assert!(!prefs.contains_key("fontsFolder"), "{r}"); + let _ = std::fs::remove_dir_all(base); +} + /// `ui.inspect` hides a hidden library's id and path in every string it holds: status text, the /// command palette query and a script's report show the id and file name shown, and the path appears /// nowhere in the reply. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0c567096a4..75141ea9a6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -769,7 +769,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - `ui.inspect` under `--automation-read-root`: the documents still opening, the recent files, library panels' files and folder preferences (saved, and in an open Preferences dialog) outside the read roots are left out of the reply, a library loaded from outside them shows by its file name in `ui.inspect` and `ui.docking`, and the app's state is not changed. `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_leaves_out_paths_outside_the_read_root`). - Link replies under `--automation-read-root`: `links.list`, `links.check`, `links.info` and `document.open`'s link rows leave out the path, found path and folder of a linked file outside the read roots, and the stored size and date of it in `links.info`; each row keeps its id, file name and status, and the document's links are not changed. `crates/engine/src/tests/linkspanel.rs` (`link_paths_outside_the_read_roots_are_left_out`), `crates/engine/src/tests/placed_document.rs` (`link_info_leaves_out_a_placed_documents_path_outside_the_read_roots`). - Linked-file and font-file searches under `--automation-read-root`: `links.findFiles` and `text.findFontFiles` replies, also later polls and stops of a search another client started, leave out the files found, a link's saved path and the folder searched outside the caller's read roots, and a failed search gives its reason without the path; `image.info` gives `link: null` for a linked file outside them; `text.addFontFiles` gives `folder: null` and the copies' file names when the Fonts folder is outside them. `crates/engine/src/tests/linkfiles.rs`, `crates/engine/src/tests/fontfiles.rs`, `crates/engine/tests/font_folder.rs`. - - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. + - Swatch, brush, symbol and graphic style library lists under `--automation-read-root`: every library stays listed, the paths of library files and the user library folder outside the read roots are `null`, a library loaded from outside them is listed and found as `loaded/<file name>` and keeps that id for the session unless a library loaded later without a file shows that id itself, which moves it to the next free `loaded/<file name> (N)`, and the app's own list is not changed. `crates/engine/src/tests/library_roots.rs`. - Preferences commands under `--automation-read-root`: `prefs.get`, `prefs.list`, `prefs.set` and `prefs.reset` show folder preferences outside the read roots as `null` and leave out an export preset's folder outside them, and the preferences are not changed. `crates/engine/src/tests/prefs_roots.rs`. - Plug-in, recovery and script report replies under `--automation-read-root`: `plugin.list`, `plugin.info` and `plugin.safeMode` name the plug-in folder, failed files, plug-in sources and disabled plug-ins' files outside the read roots by file name or `null`, `file.recovery.*` leave out a document's original file and the recovery folder outside them, and `ui.inspect`'s `scriptReport` names files a run wrote outside them by file name. `crates/engine/src/tests/plugins.rs` (`plugin_list_leaves_out_paths_outside_the_read_root`), `crates/engine/src/tests/recovery.rs` (`recovery_replies_leave_out_paths_outside_the_read_root`), `crates/ui-egui/src/tests/automation.rs` (`ui_inspect_script_report_leaves_out_files_outside_the_read_root`). - Scripts: `alert`, `confirm` and `prompt` open dialogs during a run, each with a Stop button that ends the run and puts every document back. In the app the run waits at the call for the person's answer; a run over MCP or the command line takes its answers in order from `script.run`'s `answers`, and the report lists each dialog with its answer. `crates/engine/src/tests/script_dialogs.rs`, `crates/ui-egui/src/tests/script_dialogs.rs`. From 91e1412d779a15a59b01b46f9125221913ae659b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 07:50:46 -0800 Subject: [PATCH 30/35] Effects: Roughen at size 0 keeps the outline With Size 0, Roughen moved no point but still replaced each segment with its Detail's points joined by straight lines (Corner) or by a smoothed curve through them (Smooth), so a curved path lost its shape. At Size 0 each segment is now split at the Detail spacing into pieces of equal length on the segment itself, with `pathtools::insert_at`: a straight segment gets corner points without handles, and a curve gets smooth points whose handles follow it. The path keeps its outline with Smooth or Corner points alike. The params text of `distort.roughen` describes size 0. `insert_at` now counts the anchors added before each returned mark in one pass. It counted them again for every split segment, which took quadratic time on a path with many split segments. Its results are unchanged. Existing documents with a Roughen of Size 0 on a curved path, or with Smooth points, draw differently. This is VectorCraft's own rule. What Roughen does is described in https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html --- crates/effects/src/catalog.rs | 2 +- crates/effects/src/distort.rs | 23 ++++- crates/effects/src/tests.rs | 90 +++++++++++++++++++ crates/eps/src/import/ai/style/tests.rs | 3 +- .../pathops/src/addons/pathtools/crossings.rs | 14 ++- docs/target-app-parity.md | 1 + 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 84991392b2..d22a4b06b3 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -258,7 +258,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> { "distort.roughen", "Roughen…", DT, - "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + "{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), ), g( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index c070457027..a5bc941b16 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -3,6 +3,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; +use vectorcraft_pathops::addons::pathtools::insert_at; use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points}; @@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000; /// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where /// that would make more than `max` anchors, the points are spaced further apart until it doesn't -/// (stacked Roughens multiply their points, #3578). +/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is +/// split into its pieces, evenly along its length, and the outline stays as it is. pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let size = num(p, "size", 5.0).clamp(0.0, 1e4); let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size }; @@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect(); let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count())); let mut lens = lens.iter(); + if amp == 0.0 { + // Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them. + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let mut places = vec![]; + for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) { + let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS); + let c = sp.segment(i); + let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY); + if len > 1e-9 && len.is_finite() { + places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY)))); + } + } + subs.push(insert_at(sp, &places).0); + } + return PathData::new(subs); + } let smooth = smooth_points(p); let seed = seed(p); let mut subs = vec![]; @@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { PathData::new(subs) } +const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4; + /// The move and the number of copies of a Transform effect with params `p` that only moves what /// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects, /// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index e63b38a462..5246a639e8 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1251,6 +1251,96 @@ fn roughen_smooth_makes_no_cusps() { } } +#[test] +fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() { + use vectorcraft_geom::AnchorKind; + use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let curve = PathData::single(SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)), + vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b), + ], + false, + )); + let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0)); + // Two subpaths with lines and curves: a closed one whose last segment is a line, and an open + // one that ends with a line. + let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0)); + let mixed = PathData::new(vec![ + SubPath::new( + vec![ + vectorcraft_geom::Anchor::corner(a), + vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)), + vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c), + ], + true, + ), + SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)), + vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e), + vectorcraft_geom::Anchor::corner(f), + ], + false, + ), + ]); + for path in [square(), ellipse, curve, mixed] { + let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect(); + let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt(); + for params in + [json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})] + { + let out = run("distort.roughen", params.clone(), &path); + assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added"); + assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}"); + for (sp, got) in path.subpaths.iter().zip(&out.subpaths) { + assert_eq!(got.closed, sp.closed); + // Walk the result one original segment at a time: its pieces end on the segment's end anchor. + let mut at = 0; + for i in 0..sp.segment_count() { + let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]); + let start = got.anchors[at]; + let was = sp.anchors[i]; + assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + let mut pieces = vec![]; + loop { + let piece = got.segment(at); + for j in 0..=8 { + let q = piece.eval(j as f64 / 8.0); + assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q)); + } + pieces.push(piece.arclen(1e-6)); + at += 1; + if piece.p3.distance(end.p) < 1e-9 { + break; + } + // A point the effect added: a corner without handles on a line, a smooth + // point whose handles follow the curve. + let added = got.anchors[at]; + if sp.segment_is_line(i) { + assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}"); + } else { + assert_eq!(added.kind, AnchorKind::Smooth, "{params}"); + let (hi, ho) = (added.h_in - added.p, added.h_out - added.p); + assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}"); + } + } + assert!(pieces.len() > 1, "{params}: segment {i} is split"); + assert!(close(pieces.iter().sum::<f64>(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}"); + let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l))); + assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}"); + } + assert_eq!(at, got.segment_count(), "{params}"); + if !sp.closed { + let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + } + } + } + } +} + #[test] fn zigzag_ridge_count_and_amplitude() { let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square()); diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index 47086ca171..b4481b60cc 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1027,8 +1027,7 @@ fn affine_corners_are_a_parallelogram_however_far_they_reach() { #[test] fn roughen_keeps_the_drawn_look() { - // Its outline is random and the file holds no seed; at a size of 0 the effect still redraws the - // path's segments. + // Its outline is random and the file holds no seed. assert_eq!(read_effect("Adobe Roughen", "30 /Real (size) ,\n"), None); assert_eq!(read_effect("Adobe Roughen", ""), None); } diff --git a/crates/pathops/src/addons/pathtools/crossings.rs b/crates/pathops/src/addons/pathtools/crossings.rs index e1d96ed0fd..d216a2f4b3 100644 --- a/crates/pathops/src/addons/pathtools/crossings.rs +++ b/crates/pathops/src/addons/pathtools/crossings.rs @@ -182,7 +182,19 @@ pub fn insert_at(sp: &SubPath, places: &[(usize, f64)]) -> (SubPath, BTreeSet<us out.anchors.push(a); out.anchors.extend(added.remove(&i).unwrap_or_default()); } - let added_before = |j: usize| inner.range(..j).map(|(_, v)| v.len()).sum::<usize>(); + // How many anchors were added before each anchor, counted in one pass for all of them. + let mut before = vec![0usize; n + 1]; + for (&seg, ts) in &inner { + if let Some(b) = before.get_mut(seg + 1) { + *b += ts.len(); + } + } + let mut sum = 0; + for b in &mut before { + sum += *b; + *b = sum; + } + let added_before = |j: usize| before.get(j).copied().unwrap_or(0); let mut marks: BTreeSet<usize> = at_anchor.into_iter().map(|j| j + added_before(j)).collect(); for (&seg, ts) in &inner { let base = seg + added_before(seg); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index b172aefd7e..dcfe0c00e3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -598,6 +598,7 @@ since 2026-10-01. - **Distort Appearance with Transform effect options:** with Envelope Options › Distort Appearance on, a path whose Transform effect has Scale Strokes & Effects or Transform Patterns on, or copies it with a gradient, is bent as the same per-copy art it draws and expands as outside the envelope: each copy's own stroke weight, dashes, pattern placement and gradient (effects `bake::tests`). Scores and hours unchanged. - **Fitted gradients turn with the Transform effect:** a gradient fitted to the object (not placed with the Gradient tool) turns, reflects and stretches with the object when its Transform effect rotates, reflects or scales it unevenly, as Object › Transform already does: the gradient is fixed to its fit on the geometry the effect takes, then follows the effect and the effects after it as a placed gradient does. Under moves and even scales it draws as a refit does, and a Transform effect that scales the object to nothing across leaves it fitted, with Scale Strokes & Effects or Transform Patterns on too, and on each copy. This holds on the canvas and in Object › Expand of the fill, for an object's own effects, a fill's or stroke's own, and a group's, where the gradients fitted to its paths, to its own fill, to the groups inside it and to its images' and symbol instances' own fills each fix to their own object's fit. Existing documents with such a Transform effect on fitted-gradient art draw differently: before, the gradient refit to the turned outline. The Gradient tool still shows a fitted gradient's vector at its unturned fit. How gradients follow the effect is VectorCraft's own rule; what the effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_fitted_gradient_turns_reflects_and_stretches_with_a_transform_effect`, `bake::tests::a_transform_effect_that_scales_to_nothing_leaves_a_fitted_gradient_fitted`, `group::tests::a_groups_transform_turns_the_gradients_fitted_to_its_members_and_its_own_fill`; render `objectfx::a_fitted_gradient_turns_and_reflects_with_its_transform_on_the_canvas`; engine `objexpand::a_fitted_gradient_turned_by_a_transform_expands_as_the_canvas_draws_it`). Scores and hours unchanged. - **Placed gradients follow Transform and Free Distort effects:** a gradient placed with the Gradient tool moves, turns and scales with the object when the object's Transform effect moves its outline, and each copy of the Transform effect takes it as many times as it takes the transform. Under Free Distort it moves and scales from the path's bounds before the effect to its bounds after it. This holds on the canvas, in export, in Expand Appearance, in Pathfinder and compound shapes, and in Object › Expand of the fill, for an object's own effects, a group's (the own fills of the group, of the groups inside it, of a symbol's art, and of the images and symbol instances it moves, included) and a fill's or stroke's own. A gradient on a path inside a group with a Transform effect now moves with the path (#2214 moved only the gradients of type), and so does a gradient on an image's or symbol instance's own fill. When a path keeps its shape because the effect would pass the geometry budget, the group's own gradients stay in place; a hidden path in a layer, which the layer's bounds leave out, does not hold them. A freeform gradient whose points were edited moves its points in the same way. How gradients follow each effect is VectorCraft's own rule; what each effect does to the object is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `bake::tests::a_baked_transform_moves_a_placed_gradient_with_the_path`, `bake::tests::a_strokes_own_transform_and_free_distort_move_its_placed_gradient`, `bake::tests::a_fills_gradient_follows_the_effects_around_its_transform_with_options`, `bake::tests::baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds`, `group::tests::a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths`, `group::tests::a_groups_transform_moves_the_gradient_of_its_own_fill`, `group::tests::a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort`, `group::tests::a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills`, `group::tests::shape_art_moves_gradients_with_a_transform_effect`, `group::tests::shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform`, `tests::art_past_the_budget_keeps_its_own_gradients_in_place`, `tests::a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move`, `tests::edited_freeform_points_follow_a_transform_effect_and_free_distort`; render `objectfx::a_paths_gradient_moves_with_its_transform_and_free_distort`; engine `objexpand::a_placed_gradient_moved_by_a_transform_or_free_distort_expands_as_the_canvas_draws_it`). Scores and hours unchanged.- **Constrain Angle (#394):** General › Constrain Angle turns the arrow keys' axes (guides stay on the page's), holds Shift drags of the Selection, Direct Selection, Free Transform, Slice Selection, Reshape and Measure tools and the Pen's handles to the turned axes, and new rectangles, rounded rectangles, ellipses, grids, point and area type, Shift-drawn polygons and stars and the shape size dialogs are drawn along it with their bounding box turned; 0° and whole turns are exactly as before, quarter turns exact (<https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>). Tests: `tests_constrain_tools`, `tests_constrain_new` (engine), `geom` `constrain_axes` / `rotate_deg`, the shape, grid and type tool tests. +- **Roughen at Size 0 keeps the outline:** with Size 0 (relative or absolute), Effect › Distort & Transform › Roughen… moves no point: each segment is split at the Detail spacing (fewer points past the anchor limit) into pieces of equal length on the segment itself, so the path keeps its outline, with Smooth or Corner points alike. A straight segment gets corner points without handles, and a curve gets smooth points whose handles follow it. Before, the points were joined by straight lines (Corner) or a smoothed curve through them (Smooth), which changed curved outlines. Existing documents with a Roughen of Size 0 on a curved path, or with Smooth points, draw differently. This is VectorCraft's own rule; what Roughen does is described in [Distort and transform effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/distort-and-transform-effects-in-illustrator.html) (effects `tests::roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly`). Scores and hours unchanged. - **Clipping paths on their outline (G1):** the selection tools (Selection, Direct Selection, Group Selection), Object Highlighting and a right-click take a clipping path on its outline before the art it clips; Select Behind lists the same; the eyedropper, the tools that act on what is drawn and the selection tools' double-clicks, loaded text and guide release take the art. Doc and tool regression tests; score unchanged. - **Copy and Cut of anchors picked with a lent Direct Selection (#6123):** with the Pen or the Curvature tool, Cmd/Ctrl held lends Direct Selection for the gesture; the anchors and segments it picks are what Edit › Copy and Cut take once the key is let go, as with Direct Selection chosen, while the last anchor the Pen leaves selected as it draws still makes Copy take the whole path (#2122). A Cmd gesture that leaves the selection as it was (a Shift marquee over nothing) picks nothing. Regression test: `tests::draw2::pen_cmd_selected_anchors_are_what_copy_and_cut_take`. From 70b26c12fb3a2343aa88e7ff5de3edda6aecc53d Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:02:40 -0800 Subject: [PATCH 31/35] Updates: take craft-update from craft-libs main at e98299546 craft-update and craft-vectorcraft-format move from storytold/craft-libs at 6e03f65bf to e98299546, craft-libs main with 47b6bbadf. The startup check's thread now reads whether the copy installs itself, so a slow disk no longer stalls a frame when the startup prompt is built. Both crates stay on one craft-libs revision; the other craft-libs pins are unchanged. Cargo.lock changes only the two crates' source. --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f9c3dacaa3..a4eac55700 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "chrono", "dunce", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "flate2", "serde", diff --git a/Cargo.toml b/Cargo.toml index 29f43866c2..ec55a218f0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,11 +21,11 @@ craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280" } # craft-update: app-specific pretend versions apply to startup and manual checks. # Native Affinity documents (craft-libs#914). craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } From c8a6b4d9c3c458d5e28708a18293ec98f019fa7b Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:12:40 -0800 Subject: [PATCH 32/35] MCP: a base64 thumbnail in a reply is sent as it is Under automation read roots, a reply's base64 `thumbnail` (a library item's PNG in `library.get`, a page's PNG in `document.pdfInfo`) was searched for hidden library paths like any other text, so a short path that happened to occur in the encoded bytes was rewritten and the image broke. `thumbnail` now joins `dataBase64`, `pngBase64` and `thumbnailBase64` as encoded data that is left unchanged; text under it that is not base64 is still searched. --- crates/ui-egui/src/control.rs | 5 +++-- crates/ui-egui/src/control/tests.rs | 10 +++++++--- docs/mcp.md | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f568f5446c..002a739d23 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -572,8 +572,9 @@ struct Rewrite<'a> { fold: bool, } -/// The keys of encoded file data in a reply. -const BASE64_FIELDS: [&str; 3] = ["dataBase64", "pngBase64", "thumbnailBase64"]; +/// The keys of encoded file data in a reply. `thumbnail` holds the PNG of a library item in +/// `library.get` and of a page in `document.pdfInfo`. +const BASE64_FIELDS: [&str; 4] = ["dataBase64", "pngBase64", "thumbnailBase64", "thumbnail"]; /// Is `s` written in the base64 alphabet, padded to a multiple of 4? fn is_base64(s: &str) -> bool { diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 6ae364e172..5797b0ddb9 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -40,14 +40,16 @@ fn hide_under_a_root(tag: &str, reply: &mut Value, hidden: &super::Hiding) { let _ = std::fs::remove_dir_all(root); } -/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64` or -/// `thumbnailBase64`, stays as it is when it holds a hidden library's path. Text that is not base64 -/// under such a key, an object or a list under it, and any other key ending in `Base64` are searched. +/// Encoded file data in a reply, a base64 string under `dataBase64`, `pngBase64`, `thumbnailBase64` +/// or `thumbnail` (a library item's or a PDF page's PNG), stays as it is when it holds a hidden +/// library's path. Text that is not base64 under such a key, an object or a list under it, and any +/// other key ending in `Base64` are searched. #[test] fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { let hidden = hiding(&[("loaded/assets/icon", "loaded/icon")], &[]); let data = "QUJ+assets/iconREVGxQQ=="; let mut reply = json!({"ok": true, "result": { + "graphics": [{"name": "a", "thumbnail": data}, {"name": "b", "thumbnail": "assets/icon.png"}], "files": [{"name": "a.png", "dataBase64": data, "pngBase64": "assets/icon.png", "thumbnailBase64": data}], "status": "from assets/icon", "dataBase64": {"note": "from assets/icon"}, @@ -58,6 +60,8 @@ fn a_reply_keeps_encoded_data_that_holds_a_hidden_path() { let files = &reply["result"]["files"][0]; assert_eq!((&files["dataBase64"], &files["thumbnailBase64"]), (&json!(data), &json!(data)), "{reply}"); assert_eq!(files["pngBase64"], "icon.png", "{reply}"); + let graphics = &reply["result"]["graphics"]; + assert_eq!((&graphics[0]["thumbnail"], &graphics[1]["thumbnail"]), (&json!(data), &json!("icon.png")), "{reply}"); assert_eq!(reply["result"]["status"], "from icon", "{reply}"); assert_eq!(reply["result"]["dataBase64"]["note"], "from icon", "{reply}"); assert_eq!(reply["result"]["pngBase64"][0], "from icon", "{reply}"); diff --git a/docs/mcp.md b/docs/mcp.md index faae559e61..ab3a372be9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -187,7 +187,7 @@ the suite) keep the agent to the folders you give it: matched before the paths the request sent, so a sent path written over part of a hidden library's path does not keep that path in the reply. A relative path is not replaced where it ends a longer path (`/work/data/icon.svg` keeps `data/icon.svg`), and the absolute path it reads as from the app's working folder is replaced too. Encoded file data, a base64 string under `dataBase64`, - `pngBase64` or `thumbnailBase64`, is sent as it is. + `pngBase64`, `thumbnailBase64` or `thumbnail` (`library.get`, `document.pdfInfo`), is sent as it is. - `swatch.library.list`, `brush.libraries`, `symbol.libraries` and `graphicStyle.libraries` list every library but leave out the paths outside the read roots: a library's `path` and the user library folder in `userFolder` are `null` there. A library loaded from a file outside the roots is listed as `loaded/<file name>`, numbered From 3e46408ab572d2e61d1504e8ae37c06774f84e9f Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:25:14 -0800 Subject: [PATCH 33/35] EPS import: cshow and kshow procedures see the font PLRM gives them In a CID-keyed font whose CIDFont the file only names, a cshow procedure now runs with the CIDFont as the current font, rootfont gives the composite font, and the procedure gets each code's last byte. A show in the CIDFont shows the character of that code, and any other string is a rangecheck error. The current font is restored after each procedure. A kshow procedure no longer leaves a font it sets current: the current font is restored when the procedure completes, for every font, and a kshow the procedure runs sees the font the procedure set. In GBT-H and CNS2-H, whose mappings are not read, a byte that starts no row-and-cell code is a code of its own. docs/gaps.md dates this G9 work in a bullet of its own. --- crates/eps/src/import/interp.rs | 4 + crates/eps/src/import/predefined_cmap.rs | 7 +- crates/eps/src/import/tests_cid.rs | 57 +++++++++-- crates/eps/src/import/text.rs | 123 +++++++++++++++++------ docs/gaps.md | 3 +- 5 files changed, 150 insertions(+), 44 deletions(-) diff --git a/crates/eps/src/import/interp.rs b/crates/eps/src/import/interp.rs index 6b96aabc25..0ea7017c11 100644 --- a/crates/eps/src/import/interp.rs +++ b/crates/eps/src/import/interp.rs @@ -98,6 +98,9 @@ pub(crate) struct Interp<'a> { /// The font a `kshow` procedure sees as current while type in a CIDFont only named is set in /// a font of its own (see `text`). pub kshow_font: Option<DictRef>, + /// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as + /// current (see `text`). + pub cshow: Option<super::text::CShowFont>, pub g: GState, pub saved: Vec<GState>, pub out: Out, @@ -240,6 +243,7 @@ impl<'a> Interp<'a> { cmap_font: 0, predefined_cmaps: vec![], kshow_font: None, + cshow: None, type42_counted: HashSet::new(), g, saved: vec![], diff --git a/crates/eps/src/import/predefined_cmap.rs b/crates/eps/src/import/predefined_cmap.rs index 19525fdf36..c688ebf9ae 100644 --- a/crates/eps/src/import/predefined_cmap.rs +++ b/crates/eps/src/import/predefined_cmap.rs @@ -288,7 +288,7 @@ fn code_space(cmap: &str) -> Option<CodeSpace> { "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, _ if stem.ends_with("RKSJ") => RKSJ, "EUC" | "78-EUC" => EUC_JP, - "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "" | "78" | "Add" | "Ext" | "NWP" | "GBT" | "CNS2" => ROW_CELL, "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, "GBpc-EUC" | "GBTpc-EUC" => GB_PC, "GBK-EUC" | "GBKp-EUC" => GBK, @@ -327,8 +327,9 @@ pub(super) fn known(cmap: &str) -> bool { /// The codes of string `bytes` in predefined CMap `cmap` when its mappings can't be read, each /// standing for no character: as long as the code space range it falls in ([`code_space`]; a byte -/// that starts no code is a code of its own), or two bytes when the code space isn't listed (the -/// row-and-cell codes of `GBT-H` and `CNS2-H`). A 0x0D or 0x09 byte is always a code of its own. +/// that starts no code is a code of its own, as in the row-and-cell code space of `GBT-H` and +/// `CNS2-H`), or two bytes when the code space isn't listed. A 0x0D or 0x09 byte is always a code +/// of its own. pub(super) fn unread_codes(cmap: &str, bytes: &[u8]) -> Decoded { let space = code_space(cmap); let mut out = Decoded::default(); diff --git a/crates/eps/src/import/tests_cid.rs b/crates/eps/src/import/tests_cid.rs index 25ed81c2b0..8350cd514b 100644 --- a/crates/eps/src/import/tests_cid.rs +++ b/crates/eps/src/import/tests_cid.rs @@ -487,21 +487,29 @@ fn named_cidfonts_over_predefined_cmaps_take_the_show_operators_spacing() { measured(&format!("{set} 10 20 moveto <93fa967b> [30 30] xshow currentpoint pop 70 sub abs 0.01 lt")); measured(&format!("{set} 10 20 moveto <93fa967b> [10 10] yshow currentpoint 40 sub abs 0.01 lt exch 10 sub abs 0.01 lt and")); measured(&format!("{set} 10 20 moveto <93fa967b> [30 10 30 10] xyshow currentpoint 40 sub abs 0.01 lt exch 70 sub abs 0.01 lt and")); - // `kshow` runs its procedure between each two codes: it moves the point 100 more here, and a - // font it sets stays current. + // `kshow` runs its procedure between each two codes: it moves the point 100 more here. The + // current font is restored when the procedure completes (PLRM 3rd ed., `kshow`), so a font it + // sets doesn't stay current, here or for a font found by name. measured(&format!( "{set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop 100 0 rmoveto}} <93fa967b> kshow currentpoint \ 3 -1 roll sub abs 0.01 lt 3 1 roll sub -100 sub abs 0.01 lt and" )); measured(&format!( - "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Helvetica eq" + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont}} <93fa967b> kshow currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq" )); - // The procedure sees the font the file set as current, and the next one the font an earlier - // one set. + measured( + "/Helvetica findfont 20 scalefont setfont 10 20 moveto {pop pop /Courier findfont 10 scalefont setfont} (AB) kshow currentfont /FontName get /Helvetica eq", + ); + // Each procedure sees the font the file set as current, also after an earlier one set another. measured(&format!("{set} 10 20 moveto {{pop pop currentfont /FontName get /seen exch def}} <93fa967b> kshow seen /Ryumin-Light-90ms-RKSJ-H eq")); measured(&format!( "{set} 10 20 moveto /n 0 def {{pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont}} {{currentfont /FontName get /seen exch def}} ifelse}} \ - <93fa967b41> kshow seen /Helvetica eq" + <93fa967b41> kshow seen /Ryumin-Light-90ms-RKSJ-H eq" + )); + // A `kshow` the procedure runs in a font it set sees that font. + measured(&format!( + "{set} 10 20 moveto {{pop pop /Helvetica findfont 10 scalefont setfont {{pop pop currentfont /FontName get /seen exch def}} (AB) kshow}} \ + <93fa967b> kshow seen /Helvetica eq" )); // The procedure gets the two codes' values (left on the stack here). measured(&format!( @@ -575,8 +583,9 @@ fn named_cid_type_is_set_alike_by_every_show_operator() { /// `charpath`, `cshow` and `glyphshow` in a CID-keyed font over a predefined CMap, its CIDFont only /// named: `charpath` adds the outlines of the characters the codes stand for and moves the point -/// as `show` does; `cshow`'s procedure gets each code (two bytes for a two-byte code) with its -/// width; `glyphshow` sets the named character itself. +/// as `show` does; `cshow`'s procedure gets each code's last byte with its width and runs with the +/// CIDFont current, a show in it setting the code's character; `glyphshow` sets the named +/// character itself. #[test] fn named_cid_type_in_charpath_cshow_and_glyphshow() { let set = "/Ryumin-Light-90ms-RKSJ-H findfont 20 scalefont setfont"; @@ -595,9 +604,35 @@ fn named_cid_type_in_charpath_cshow_and_glyphshow() { top("fd41"), top("41") )); - // The procedure leaves each code: 日 (0x93FA), then A. - check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#93fa eq exch 1 get 65 eq and and")); + // The procedure leaves each code's last byte (PLRM 3rd ed., `cshow`): 日 (0x93FA), then A. + check(&format!("{set} [ {{pop pop}} <93fa41> cshow ] dup length 2 eq exch dup 0 get 16#fa eq exch 1 get 65 eq and and")); check(&format!("{set} 0 {{pop exch pop add}} <93fa967b41> cshow <93fa967b41> stringwidth pop sub abs 0.01 lt")); + // While the procedure runs, the current font is the CIDFont and `rootfont` gives the composite + // font (PLRM 3rd ed., `cshow` and `rootfont`); the current font is restored after each run, + // also when the procedure sets another. + check(&format!( + "{set} {{pop pop pop currentfont dup /FontType get /t exch def /CIDFontName get /seen exch def}} <93fa> cshow \ + seen /Ryumin-Light eq t 9 eq and" + )); + check(&format!("{set} {{pop pop pop rootfont /FontName get /seen exch def}} <93fa> cshow seen /Ryumin-Light-90ms-RKSJ-H eq")); + check(&format!( + "{set} /n 0 def {{pop pop pop /n n 1 add def n 1 eq {{/Helvetica findfont 10 scalefont setfont rootfont /FontName get /r exch def}} \ + {{currentfont /CIDFontName get /seen exch def}} ifelse}} <93fa41> cshow seen /Ryumin-Light eq r /Helvetica eq and \ + currentfont /FontName get /Ryumin-Light-90ms-RKSJ-H eq and" + )); + // A show in the CIDFont shows the character of the code the procedure was given, the string + // holding its last byte, and moves the point as far as `show` does. Any other string, an + // empty one included, is a `rangecheck` error. + for s in ["()", "(AB)", "<41>"] { + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop {s} show}} <93fa> cshow}} stopped grestore")); + } + check(&format!("gsave nulldevice {set} 10 20 moveto {{{{pop pop pop <fa> show}} <93fa> cshow}} stopped not grestore")); + let r = super::tests::read(&format!("{set} 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow")); + assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "日本"); + check(&format!( + "gsave nulldevice {set} 10 20 moveto <93fa967b> show currentpoint 10 20 moveto {{pop pop ( ) dup 0 4 -1 roll put show}} <93fa967b> cshow \ + currentpoint grestore 3 -1 roll sub abs 0.01 lt 3 1 roll sub abs 0.01 lt and" + )); let r = super::tests::read(&format!("{set} 10 20 moveto /uni00E9 glyphshow")); assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["é"]); } @@ -640,6 +675,8 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() { "/Ryumin-Light-GBT-V findfont 20 scalefont setfont 10 100 moveto <21212121> show currentpoint 60 sub abs 0.01 lt exch 10 sub abs 0.01 lt and", // A tab is a code of its own: three codes. "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <09092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", + // A byte that starts no row-and-cell code is a code of its own, also before a tab: three. + "/Ryumin-Light-GBT-H findfont 20 scalefont setfont <21092121> stringwidth abs 0.01 lt exch 60 sub abs 0.01 lt and", ] { let r = super::tests::read(&format!("{cond} {{ 0 0 10 10 rectfill }} if")); assert!(all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Path { .. })), "{cond}"); diff --git a/crates/eps/src/import/text.rs b/crates/eps/src/import/text.rs index 73fc4776d0..989450f440 100644 --- a/crates/eps/src/import/text.rs +++ b/crates/eps/src/import/text.rs @@ -186,22 +186,15 @@ impl Interp<'_> { // The procedure shows or places each character: it gets its code and width. let s = self.pop_str()?.to_vec(); let proc = self.pop_proc()?; - // In a CIDFont only named, a code may be several bytes: the procedure gets each - // code's value. + // In a CIDFont only named, a code may be several bytes: the procedure runs once for + // each code. if let Some(named) = self.named_cid_type(&s)? { - let mut chars = named.codes.text.chars(); - for &(code, n) in &named.codes.codes { - let mut one = Decoded::default(); - one.push(code, chars.by_ref().take(n)); - let v = self.named_cid_width(&named, &one)?; - self.push(Obj::Int(i64::from(code)))?; - self.push_num(v.x)?; - self.push_num(v.y)?; - if !self.body(&proc)? { - break; - } - } - return Ok(()); + let root = self.g.font.clone(); + let before = self.cshow.take(); + let r = self.named_cshow(named, &proc, root.clone()); + self.cshow = before; + self.g.font = root; + return r; } for b in s { let v = self.width_of(&[b])?; @@ -252,7 +245,12 @@ impl Interp<'_> { SetCharWidth => self.glyph_width = Some(self.nums::<2>()?), SetCacheDevice2 => self.glyph_width = Some(self.nums::<10>()?[..2].try_into().unwrap_or_default()), RootFont => { - let font = match self.g.font.clone() { + // Inside a `cshow` procedure the composite font, unless the procedure set a font. + let root = match (&self.cshow, &self.g.font) { + (Some(c), Some(f)) if f.same(&c.selected) => Some(c.root.clone()), + _ => None, + }; + let font = match root.or_else(|| self.g.font.clone()) { Some(f) => f, None => self.find_font(&Obj::name(DEFAULT_FONT))?, }; @@ -791,18 +789,16 @@ impl Interp<'_> { self.push(Obj::Int(code.into()))?; self.push(Obj::Int(next.into()))?; // The procedure sees the font the file set, not the one type in a CIDFont only named is - // set in; a font it sets stays current. - let Some(outer) = self.kshow_font.take() else { - self.call(p.clone())?; - return Ok(self.user_point()? - pen); + // set in, and the current font is restored when it completes (PLRM 3rd ed., `kshow`). + // A `kshow` the procedure runs sees the font the procedure set. + let outer = self.kshow_font.take(); + let inner = match outer.clone() { + Some(outer) => self.g.font.replace(outer), + None => self.g.font.clone(), }; - let inner = self.g.font.replace(outer.clone()); let r = self.call(p.clone()); - // Once the procedure sets a font, it and the later procedures see that one. - if self.g.font.as_ref().is_some_and(|f| f.same(&outer)) { - self.g.font = inner; - self.kshow_font = Some(outer); - } + self.kshow_font = outer; + self.g.font = inner; r?; Ok(self.user_point()? - pen) } @@ -960,6 +956,15 @@ struct NamedCid { ems: bool, } +/// A `cshow` procedure running over type in a CIDFont only named ([`Interp::named_cshow`]): the +/// CIDFont it sees as current, the composite font `rootfont` gives while that CIDFont is current, +/// and the type of the code it was given. +pub(super) struct CShowFont { + selected: DictRef, + root: DictRef, + code: NamedCid, +} + /// What drawing a glyph does with it. #[derive(Clone, Copy, PartialEq)] enum Draw { @@ -1092,7 +1097,17 @@ impl Interp<'_> { /// stands for none), set as type in the font of its CIDFont's name, vertical over a vertical /// CMap (`WMode` 1). Control codes stand for no character. Type in a CMap VectorCraft can't /// read is left out, with a warning, each of its codes moving the point one em. + /// Inside a `cshow` procedure over such type, with the CIDFont current, the string must be the + /// one byte the procedure was given, else a `rangecheck` error, and it stands for the code + /// the procedure was given (PLRM 3rd ed., `cshow`: the glyph of the selected CID). fn named_cid_type(&mut self, s: &[u8]) -> Res<Option<NamedCid>> { + if let Some(named) = self.cshow_code(true) { + let given = named.codes.codes.first().map(|&(code, _)| code & 0xff); + if s.len() != 1 || given != s.first().map(|&b| u32::from(b)) { + return ps_err("rangecheck", "a string shown in cshow"); + } + return Ok(Some(named)); + } let Some(cmap) = self.named_cid_parts().map(|(cmap, ..)| cmap) else { return Ok(None) }; // Each code is looked up in the CMap's mappings. self.spend(s.len() as u64)?; @@ -1120,6 +1135,9 @@ impl Interp<'_> { /// The font [`Self::named_cid_type`] sets type in, with no codes yet, when the current font is /// a CID-keyed font over a predefined CMap the file doesn't define. fn named_cid_font(&mut self) -> Res<Option<NamedCid>> { + if let Some(named) = self.cshow_code(false) { + return Ok(Some(named)); + } let Some((_, font, name, vertical)) = self.named_cid_parts() else { return Ok(None) }; // The composite font is copied under the CIDFont's name for every string: charged by // its size, so a huge font dictionary shown in a loop runs out of the budget. @@ -1130,6 +1148,23 @@ impl Interp<'_> { Ok(Some(NamedCid { codes: Decoded::default(), font: DictRef::new(named), vertical, ems: vertical })) } + /// The type of the code a running `cshow` procedure was given ([`Self::named_cshow`]), with + /// that code when `code` and with none otherwise, while the CIDFont it selected is current. + fn cshow_code(&self, code: bool) -> Option<NamedCid> { + let c = self.cshow.as_ref()?; + if !self.g.font.as_ref().is_some_and(|f| f.same(&c.selected)) { + return None; + } + let mut codes = Decoded::default(); + if code { + let mut chars = c.code.codes.text.chars(); + for &(value, n) in &c.code.codes.codes { + codes.push(value, chars.by_ref().take(n)); + } + } + Some(NamedCid { codes, font: c.code.font.clone(), vertical: c.code.vertical, ems: c.code.ems }) + } + /// The CMap's name, the composite font, the CIDFont's name and the writing mode of /// [`Self::named_cid_type`]. fn named_cid_parts(&self) -> Option<(String, DictRef, Obj, bool)> { @@ -1191,13 +1226,41 @@ impl Interp<'_> { let before = std::mem::replace(&mut self.kshow_font, saved.clone()); let r = self.show_codes(&named.codes, widths.as_deref(), spacing, named.vertical, &kern, draw); self.kshow_font = before; - // A font the `kshow` procedure set stays current. - if self.g.font.as_ref().is_none_or(|f| f.same(&named.font)) { - self.g.font = saved; - } + self.g.font = saved; r } + /// `cshow`'s procedure `proc` run for each code of type `named` in composite font `root`, with + /// the code's last byte and its width (PLRM 3rd ed., `cshow`). While it runs the CIDFont is the + /// current font and `rootfont` gives `root`; a show operator in the CIDFont shows the code's + /// character, whatever its string (see [`Self::named_cid_type`]). The current font is restored + /// when the procedure completes. + fn named_cshow(&mut self, named: NamedCid, proc: &Obj, root: Option<DictRef>) -> Res { + let Some(composite) = root.clone() else { return Ok(()) }; + let dep = named.font.borrow().get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)); + let selected = match dep { + Some(Obj::Dict(d)) => d, + _ => named.font.clone(), + }; + let mut chars = named.codes.text.chars(); + for &(code, n) in &named.codes.codes { + let mut one = NamedCid { codes: Decoded::default(), font: named.font.clone(), vertical: named.vertical, ems: named.ems }; + one.codes.push(code, chars.by_ref().take(n)); + let v = self.named_cid_width(&one, &one.codes)?; + self.push(Obj::Int(i64::from(code & 0xff)))?; + self.push_num(v.x)?; + self.push_num(v.y)?; + self.cshow = Some(CShowFont { selected: selected.clone(), root: composite.clone(), code: one }); + self.g.font = Some(selected.clone()); + let go = self.body(proc); + self.g.font = root.clone(); + if !go? { + break; + } + } + Ok(()) + } + /// How far codes `codes` of type `named` move the current point (user space) when /// [`Self::show_named_cid`] shows them with no extra spacing. fn named_cid_width(&mut self, named: &NamedCid, codes: &Decoded) -> Res<Vec2> { diff --git a/docs/gaps.md b/docs/gaps.md index 09c31c8ae8..c668a3f9c9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -195,7 +195,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Narrowed (#6165, 2026-11-09):** editable PDF import preserves explicit ActualText once, with empty/nested spans, authored order and glyph advances covered by `crates/pdf/src/tests/actual_text.rs`. One replacement uses the first placed glyph's line/style; exact redistribution across lines/styles and general mixed-direction layout remain. Input is bounded to depth 128 and 64 KiB encoded per accepted replacement. This does not close G9's broader `.ai`/EPS gaps or change its estimate. -- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined CMaps (Unicode, Shift-JIS, EUC, GBK, Big Five, UHC) set as type, vertical over `-V` CMaps) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. +- **Narrowed (2026-11-12):** named CIDFonts over predefined CMaps other than Unicode CMaps (Shift-JIS, EUC, GBK, Big Five, UHC) are set as type too, vertical over `-V` CMaps, also by `charpath`, `cshow`, `kshow` and `glyphshow`. This narrows G9; scores and estimates unchanged. +- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9. - **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged. - **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged. From 812cf5f4cd6ca5b864436ed2b1e71e176339f6c0 Mon Sep 17 00:00:00 2001 From: dotCooCoo <RobertLeeLW@gmail.com> Date: Thu, 12 Nov 2026 09:40:31 -0800 Subject: [PATCH 34/35] .ai save: RGB values are written in the working RGB space Plain RGB fills and strokes and RGB gradient stops were written with their display RGB values. The reader reads those values as working RGB, so with a working RGB space other than sRGB a reopened file was saved with different RGB values and CMYK equivalents. They are now written with the color's own RGB values, as global process and spot colors already were. A spot color in a gradient stop now writes its RGB values from its full color as the palette writes it (Lab to four decimals and clamped), the same values its CMYK equivalent comes from. --- crates/eps/src/ai_editing/tests.rs | 83 ++++++++++++++++++++++ crates/eps/src/ai_editing/writer.rs | 8 +-- crates/eps/src/ai_editing/writer/paints.rs | 15 ++-- crates/eps/src/ai_ops.rs | 9 +++ 4 files changed, 105 insertions(+), 10 deletions(-) diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index a486feb039..42b2ab6310 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -325,6 +325,8 @@ fn cmyk_equivalents_are_written_again_unchanged() { doc.insert(Some(layer), usize::MAX, n).unwrap(); } let mut stops: Vec<GradientStop> = (0..5u16).map(|i| GradientStop::new(f32::from(i) / 5.0, rgb(i + 50))).collect(); + let out = named[1].1; + stops.push(GradientStop { swatch: Some("Out".into()), tint: 0.5, ..GradientStop::new(0.9, out.tinted(0.5)) }); stops.push(GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }); let mut fills = vec![gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None)]; fills.extend(named.iter().map(|(name, c, spot)| linked(name, *c, if *spot { 1.0 } else { 0.6 }))); @@ -339,6 +341,87 @@ fn cmyk_equivalents_are_written_again_unchanged() { same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>()); } +/// With a working RGB space other than sRGB, an RGB fill, an RGB gradient stop and an RGB spot +/// color in a stop are written with their working RGB values, the values the reader reads back, so +/// the reopened file is written with the same RGB values and CMYK equivalents. +#[test] +fn rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space() { + const CHILD: &str = "VECTORCRAFT_TEST_AI_EDITING_WIDE_GAMUT"; + if std::env::var_os(CHILD).is_none() { + // Color settings are process-wide: exercise this profile in a separate test process. + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "ai_editing::tests::rgb_values_are_written_again_unchanged_in_a_wide_gamut_working_space", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "isolated color profile test failed:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + return; + } + use vectorcraft_color::cms; + cms::set_active(&cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() }).unwrap(); + assert!(!cms::active().rgb_is_srgb()); + let mut doc = Document::new(300.0, 200.0); + let glaze = Color::rgb(0.812_345_6, 0.234_567_8, 0.456_789_1); + let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect(); + swatches.push(Swatch { name: "Glaze".into(), paint: solid(glaze), global: true, spot: true }); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + let n = square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.4, 0.2)); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.7, 0.3)), + GradientStop { swatch: Some("Glaze".into()), tint: 0.7, ..GradientStop::new(1.0, glaze.tinted(0.7)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let n = painted(&mut doc, shapes::rectangle(Rect::new(40.0, 10.0, 60.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let first = program(&doc); + assert!(first.contains("0.8 0.4 0.2 Xa"), "the fill's working RGB values are written"); + let again = String::from_utf8(export_ai_editing(&read_back(&doc), &opts()).unwrap().program).unwrap().replace("\r\n", "\n"); + same_lines(&again.lines().map(String::from).collect::<Vec<_>>(), &first.lines().map(String::from).collect::<Vec<_>>()); +} + +/// A gradient stop of a Lab spot color writes the RGB values of the Lab values as written (to four +/// decimals, clamped to the range a reader reads), the same Lab values its CMYK equivalent and the +/// palette's definition come from: for Lab values of many decimals and for Lab values out of range. +#[test] +fn a_lab_spot_stop_writes_rgb_values_of_its_lab_values_as_written() { + let mut doc = Document::new(300.0, 200.0); + let v = |i: u16, s: f32| (f32::from(i) * s + 0.123_456_7).fract(); + let mut spots: Vec<(String, Color)> = vec![("Out".into(), Color::lab(104.567_89, -140.123_46, 131.987_65))]; + spots.push(("Rim".into(), Color::lab(55.555_55, 131.234_56, -12.345_67))); + for i in 0..8u16 { + spots + .push((format!("Moss {i}"), Color::lab(v(i, 0.137_911_3) * 100.0, v(i, 0.271_828_2) * 200.0 - 100.0, v(i, 0.314_159_3) * 200.0 - 100.0))); + } + let mut swatches: Vec<Swatch> = doc.swatches.iter().cloned().collect(); + swatches.extend(spots.iter().map(|(name, c)| Swatch { name: name.clone(), paint: solid(*c), global: true, spot: true })); + doc.swatches = swatches.into(); + let layer = doc.layers[0].id; + for (i, (name, c)) in spots.iter().enumerate() { + let stops = vec![ + GradientStop::new(0.0, Color::rgb(0.1, 0.2, 0.3)), + GradientStop { swatch: Some(name.clone()), tint: 0.5, ..GradientStop::new(1.0, c.tinted(0.5)) }, + ]; + let fill = gradient_paint(Gradient::new(GradientKind::Linear, stops), None, None); + let x = i as f64 * 25.0 + 10.0; + let n = painted(&mut doc, shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), vec![AppearanceItem::Fill(FillLayer::new(fill))]); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + let program = program(&doc); + for (name, c) in &spots { + let mut want = String::new(); + crate::ps::push_nums(&mut want, &crate::ai_ops::as_written(*c).to_rgb().map(|v| f64::from(crate::ai_ops::unit(v)))); + want.push_str(&format!("({name}) 0.5 1 4 ")); + assert!(program.lines().any(|l| l.contains(&want)), "no stop of {name} writes {want:?}"); + } +} + #[test] fn the_data_is_zstandard_compressed_with_the_header_as_metadata() { let doc = sample(); diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index d5eeaeb08c..5a8ad12912 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1343,9 +1343,9 @@ impl<'a> Writer<'a> { self.plain_color(*color, stroke); } - /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB, and - /// Lab as RGB), with its CMYK equivalent (an RGB color's from its values as written, a Lab - /// color's from its Lab values). + /// A color that names no swatch: `g` (gray), `k` (CMYK), else `c m y k r g b Xa` (RGB in the + /// working RGB space, and Lab as display RGB), with its CMYK equivalent (an RGB color's from its + /// values as written, a Lab color's from its Lab values). fn plain_color(&mut self, color: Color, stroke: bool) { let s = &mut self.body; let op = |fill: &'static str, line: &'static str| if stroke { line } else { fill }; @@ -1363,7 +1363,7 @@ impl<'a> Writer<'a> { let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; let cmyk = self.cms.to_cmyk(&source, self.cms.settings().intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); let _ = writeln!(s, "{}", op("Xa", "XA")); } } diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index 81857a9501..0a7220faa5 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -371,9 +371,11 @@ impl Writer<'_> { /// A stop's color and its style: gray `lightness 0`, CMYK `c m y k 1`, RGB (and Lab, as RGB) /// `c m y k r g b 2` with its CMYK equivalent; a spot color its full color's CMYK equivalent, /// then its RGB values when it isn't CMYK, its name and how much lighter than the full color, - /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). An RGB color's CMYK equivalent is converted - /// from its values as written ([`ai_ops::as_written`]), a Lab color's from its Lab values, and a - /// spot color's is the one the palette writes for it. + /// then `3` (CMYK), or `1 4` (RGB, and Lab as RGB). RGB values are an RGB color's own (in the + /// working RGB space) and a Lab color's display RGB ([`ai_ops::rgb_values`]). An RGB color's CMYK + /// equivalent is converted from its values as written ([`ai_ops::as_written`]), a Lab color's + /// from its Lab values, and a spot color's CMYK equivalent and RGB values both come from its full + /// color as the palette writes it. fn stop_color(&mut self, s: &mut String, stop: &GradientStop) { let doc = self.doc; let intent = self.cms.settings().intent; @@ -382,11 +384,12 @@ impl Writer<'_> { && let Paint::Solid { color: full, swatch: None, .. } = sw.paint { if sw.spot { - let cmyk = self.cms.to_cmyk(&doc.linked_color(ai_ops::as_written(full), true), intent).map(unit); + let full = ai_ops::as_written(full); + let cmyk = self.cms.to_cmyk(&doc.linked_color(full, true), intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); let rgb = !matches!(full, Color::Cmyk { .. }); if rgb { - ps::push_nums(s, &full.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(full).map(f64::from)); } s.push_str(&ps::bytes(name.as_bytes())); s.push(' '); @@ -411,7 +414,7 @@ impl Writer<'_> { let source = if matches!(color, Color::Rgb { .. }) { ai_ops::as_written(color) } else { color }; let cmyk = self.cms.to_cmyk(&source, intent).map(unit); ps::push_nums(s, &cmyk.map(f64::from)); - ps::push_nums(s, &color.to_rgb().map(|v| f64::from(unit(v)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); s.push_str("2 "); } } diff --git a/crates/eps/src/ai_ops.rs b/crates/eps/src/ai_ops.rs index 14b19684f9..3b2bdd88d8 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -115,6 +115,15 @@ pub(crate) fn as_written(color: Color) -> Color { } } +/// The RGB values written for `color`, clamped to 0..1: an RGB color's own values (in the working +/// RGB space, as a reader reads them back), any other color's display RGB. +pub(crate) fn rgb_values(color: Color) -> [f32; 3] { + match color { + Color::Rgb { r, g, b } => [r, g, b].map(unit), + c => c.to_rgb().map(unit), + } +} + /// The average colour of a gradient's stops (in RGB). pub(crate) fn average(g: &Gradient) -> Color { let n = g.stops.len().max(1) as f32; From cfce3362151e4f9e15d0a00ff4d561d774888cf2 Mon Sep 17 00:00:00 2001 From: bflatastic <bombayapirat@gmail.com> Date: Fri, 13 Nov 2026 00:47:30 +0700 Subject: [PATCH 35/35] Dashes fitted to corners take a subpath with no segments An HP-GL wedge of almost no radius drawn with an adaptive line type (LT-1;EW0.000001,0,0;) leaves a lone moveto. Fitting the dashes to its corners indexed the empty segment list and panicked while rendering. Such a subpath now uses the exact plan, which has no runs to fit. Found by import_fuzz::random_hpgl_instructions_never_panic. --- crates/effects/src/stroke/dash.rs | 4 ++++ crates/effects/src/stroke/tests_fit.rs | 14 ++++++++++++++ crates/engine/tests/import_fuzz.rs | 7 +++++++ 3 files changed, 25 insertions(+) diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 400f3b58b9..f174069c4e 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -134,6 +134,10 @@ impl Plan { fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, closed: bool, period: f64) -> Self { let n = segs.len(); + // A subpath with no segments (a lone moveto) has no runs to fit. + if n == 0 { + return Self::exact(segs, lens, closed, period); + } // Corners: where segments with length meet at a kink (zero-length ones have no direction). let live: Vec<usize> = (0..n).filter(|&i| lens[i] > EPS).collect(); let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect(); diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b389cab853..cc69606aee 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -37,6 +37,20 @@ fn near(a: f64, b: f64, tol: f64) -> bool { // ---------------------------------------------------------------- dashes fitted to corners +/// A subpath with no segments (a lone moveto, as an HP-GL wedge of almost no radius gives) is +/// dashed fitted to corners without a panic, and the other subpaths keep their dashes. +#[test] +fn fitted_dashes_take_a_subpath_with_no_segments() { + let mut b = BezPath::new(); + b.move_to((5.0, 5.0)); + b.extend(line(100.0)); + let d = dash(&b, &fitted(&[10.0, 5.0], 0.0)).expect("dashed"); + assert!(moves(&d.path) > 1 && length(&d.path) > 0.0, "{:?}", d.path); + let mut lone = BezPath::new(); + lone.move_to((0.0, 0.0)); + assert!(dash(&lone, &fitted(&[10.0, 5.0], 0.0)).is_some_and(|d| d.path.elements().is_empty())); +} + #[test] fn every_corner_of_a_rectangle_falls_mid_dash() { // 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3 diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4542496fc5..4688845de4 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2958,6 +2958,13 @@ fn the_hpgl_sample_opens() { assert!(paths > 15, "{paths} paths, warnings {:?}", l.warnings); } +/// A wedge drawn with the adaptive line type `LT-1` and a near-zero radius (a case the fuzz test +/// found) opens and places without a panic. +#[test] +fn an_adaptive_line_type_wedge_of_almost_no_radius_opens() { + survive_metafile("HP-GL tiny adaptive wedge", "x.plt", b"IN;SP1;LT-1;EW0.000001,0,0;").unwrap(); +} + /// A random instruction: a mnemonic the reader plays (or one it doesn't) with hostile numbers. fn arb_hpgl_instruction() -> impl Strategy<Value = String> { let op = prop::sample::select(vec![