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" } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ebd4811959..784c8d981f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -1582,7 +1582,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/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 { "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 = 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/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, mut lens: Vec, 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 = (0..n).filter(|&i| lens[i] > EPS).collect(); let mut corners: Vec = 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/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::(), 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/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 { 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 { 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/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 988aa89572..16ab1c76d4 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -170,6 +170,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: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, @@ -268,6 +269,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/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 { 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 { "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 { 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 { 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/fileio.rs b/crates/engine/src/cmd/fileio.rs index 5754f891cc..0c20633608 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -130,7 +130,8 @@ mod tests_readonly; #[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 c3668645e1..6488972d31 100644 --- a/crates/engine/src/cmd/fileio/access.rs +++ b/crates/engine/src/cmd/fileio/access.rs @@ -65,6 +65,62 @@ 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; + } + naming_as(e, path, &error_name(path)) +} + +/// What an error names `path` by when it is outside the read roots: [`shown_path`], or +/// [`UNNAMED`] when that is empty or holds a replacement character. +pub(crate) fn error_name(path: &str) -> String { + let name = shown_path(path); + if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED.to_string() } else { name.into_owned() } +} + +/// [`naming_in_error`] with `path` named `name`, whether or not it is [`shown`] (a name worked out +/// earlier, for the request that prepared the work). +pub(crate) fn naming_as(e: EngineError, path: &str, name: &str) -> EngineError { + 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) }, + EngineError::ReadOnlyLocation(msg) => EngineError::ReadOnlyLocation(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<()> { @@ -466,3 +522,52 @@ 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 save refused by the file system keeps its type and its code. + let read_only = confine(roots.as_ref(), || naming_in_error(EngineError::ReadOnlyLocation(format!("{file}: Permission denied")), &file)); + assert!(matches!(&read_only, EngineError::ReadOnlyLocation(_))); + assert_eq!(read_only.to_string(), format!("{}: doc.svg: Permission denied", crate::READ_ONLY_LOCATION)); + // 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 01ef3f5082..915c55fb39 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -307,7 +307,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()); @@ -1056,7 +1058,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_save_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_save_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) @@ -1111,7 +1118,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 ca28407356..913f4a55a2 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -649,14 +649,14 @@ 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); // A prepared export is written later, maybe on another thread: it names the folder as this // request may see it. if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) { - export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned())); + export.hidden = Some((folder.to_string(), super::access::error_name(folder))); } if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) { leave_out_unreadable_folder(&mut r, folder); @@ -664,45 +664,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 { - leave_out_folder_named(e, folder, &super::access::shown_path(folder)) -} - -/// [`leave_out_folder_from_error`] with `folder` named `name`. -fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError { - 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`), @@ -1162,7 +1123,7 @@ impl ScreenExport { let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }); match hidden { Some((folder, name)) => { - let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?; + let mut r = r.map_err(|e| super::access::naming_as(e, &folder, &name))?; leave_out_folder(&mut r, &folder); Ok(r) } @@ -1516,7 +1477,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. @@ -1533,7 +1495,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 e0bacc3859..7727489581 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/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index d47e81befd..a208d527fa 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -389,6 +389,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 1013c606c5..8e325c69be 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); 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 + ), 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 94f865e224..b411820971 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -242,6 +242,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/cmd/recovery.rs b/crates/engine/src/cmd/recovery.rs index f3f2c68764..c1c7f789fe 100644 --- a/crates/engine/src/cmd/recovery.rs +++ b/crates/engine/src/cmd/recovery.rs @@ -1276,15 +1276,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); @@ -1295,7 +1296,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. @@ -1311,12 +1312,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), @@ -1378,7 +1381,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); @@ -1418,10 +1421,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/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 44a14dbe34..f5edf1f91d 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -297,7 +297,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/cmd/swatchlib.rs b/crates/engine/src/cmd/swatchlib.rs index 45320066cf..8101cfff97 100644 --- a/crates/engine/src/cmd/swatchlib.rs +++ b/crates/engine/src/cmd/swatchlib.rs @@ -85,7 +85,7 @@ pub fn specs() -> Vec { "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 Libraries { .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")); } @@ -469,19 +471,34 @@ 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. +/// 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() + !crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok() } /// The loaded library ids (`loaded/`) whose file the request being served may not read, each /// with the id a reply shows instead: `loaded/`, 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 -/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free -/// one of `loaded/ (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. +/// 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/ (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) -> Vec<(String, String)> { if crate::file_access::current().is_none() { return vec![]; @@ -493,7 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator hidden.push((id, name)), _ => { @@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator = 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() @@ -746,8 +779,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result { "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/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index e7ff380151..6fc8b12edc 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { "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\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; 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), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), 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), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} 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\", \"salt=2\", …] (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off; the range is shaped with its word, so a letter's alternate keeps it joined to its neighbours), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; 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), akiLeft?, akiRight?: 1/1000 em 0..1000 (Character panel › Insert Aki Left and Right, with Show East Asian Options: space before and after each character in reading order, on its left and right in left-to-right type, on its right and left in right-to-left type, above and below it in vertical type; a ligature or a letter with its marks takes the values of its first character; line wrapping counts it, alignment keeps it at a line's ends and justification does not stretch or narrow it; 0 by default; a value that is not a number or is outside the range is refused, naming the key), 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), glyphAlternate?: null (takes away the glyph choice text.setGlyphAlternate made on the characters, in the same undo step as the other attributes; any other value is refused)} 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 { "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 ), @@ -729,7 +729,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") { @@ -782,9 +782,6 @@ impl CharChange { } }, }; - if c.size.is_some_and(|v| v <= 0.0) { - return Err(bad(cmd, "size must be positive")); - } Ok(c) } @@ -1666,7 +1663,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result { 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 e71267a2de..e996591f91 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -138,7 +138,7 @@ pub fn specs() -> Vec { "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\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; 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\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; 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 Result { + 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 { @@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result { 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/file_access.rs b/crates/engine/src/file_access.rs index 73c8ba8efe..6ca724d810 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -215,6 +215,20 @@ 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`)? +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/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>, /// 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/blendopts.rs b/crates/engine/src/tests/blendopts.rs index 4b391c5714..93f531d1e8 100644 --- a/crates/engine/src/tests/blendopts.rs +++ b/crates/engine/src/tests/blendopts.rs @@ -405,6 +405,143 @@ 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); + // 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 +/// 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/engine/src/tests/brushlib.rs b/crates/engine/src/tests/brushlib.rs index 9a3a18ed91..2d363d18cf 100644 --- a/crates/engine/src/tests/brushlib.rs +++ b/crates/engine/src/tests/brushlib.rs @@ -106,7 +106,17 @@ fn the_builtin_libraries_are_original_brushes_generated_in_code() { let ids: Vec<&str> = libs.iter().map(|l| l["id"].as_str().unwrap()).collect(); assert_eq!( ids, - ["art-ribbons", "calligraphic-nibs", "bristle-brushes", "pattern-borders", "scatter-shapes", "game-trails", "energy-beams", "dry-media"] + [ + "art-ribbons", + "ink-and-wash", + "calligraphic-nibs", + "bristle-brushes", + "pattern-borders", + "scatter-shapes", + "game-trails", + "energy-beams", + "dry-media" + ] ); assert!(libs.iter().all(|l| l["category"] == "builtIn")); let lib = run(&mut s, "brush.library", json!({"library": "art ribbons"})); @@ -120,26 +130,28 @@ 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[5]["count"].as_u64(), Some(4)); + assert_eq!(libs[6]["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[6]["count"].as_u64(), Some(4)); + assert_eq!(libs[7]["count"].as_u64(), Some(4)); let dry = run(&mut s, "brush.library", json!({"library": "Dry Media"})); assert_eq!( dry["brushes"].as_array().unwrap().iter().map(|b| (b["name"].as_str().unwrap(), b["type"].as_str().unwrap())).collect::>(), [("Chalk Grain", "art"), ("Graphite Pencil", "art"), ("Wax Crayon", "art"), ("Charcoal Dust", "art")] ); - assert_eq!((libs[7]["name"].as_str(), libs[7]["count"].as_u64()), (Some("Dry Media"), Some(4))); + assert_eq!((libs[8]["name"].as_str(), libs[8]["count"].as_u64()), (Some("Dry Media"), 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"), ("bristle-brushes", "bristle"), ("pattern-borders", "pattern"), @@ -151,6 +163,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)); @@ -176,6 +189,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()) + ); +} + /// Whether `p` lies inside the polygon through `pts` (even-odd ray cast). fn inside(p: vectorcraft_geom::Point, pts: &[vectorcraft_geom::Point]) -> bool { let mut odd = false; diff --git a/crates/engine/src/tests/library_roots.rs b/crates/engine/src/tests/library_roots.rs index 30a8776b35..5ce4a3757e 100644 --- a/crates/engine/src/tests/library_roots.rs +++ b/crates/engine/src/tests/library_roots.rs @@ -214,26 +214,72 @@ 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 = 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 = 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); } + +/// Outside Windows a `\` is part of a file name: a concealed library saved at `/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] +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 = 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/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/recovery.rs b/crates/engine/src/tests/recovery.rs index 1f3867a622..976d217833 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] diff --git a/crates/engine/src/tests/save.rs b/crates/engine/src/tests/save.rs index 336382107b..5edd0d7fbd 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/crates/engine/src/tests/textedit.rs b/crates/engine/src/tests/textedit.rs index 46d41adaea..06b1cf3144 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/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 { let op = prop::sample::select(vec![ diff --git a/crates/eps/src/ai_editing/tests.rs b/crates/eps/src/ai_editing/tests.rs index 0ccfc23ca8..6c0e0c7342 100644 --- a/crates/eps/src/ai_editing/tests.rs +++ b/crates/eps/src/ai_editing/tests.rs @@ -294,6 +294,134 @@ 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 = 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 = (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 }))); + 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::>(), &first.lines().map(String::from).collect::>()); +} + +/// 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 = 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::>(), &first.lines().map(String::from).collect::>()); +} + +/// 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 = 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(); @@ -5872,25 +6000,6 @@ fn outlines(doc: &Document) -> Vec { 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::(), y.trim_start_matches("%_").parse::()) { - (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 = (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(); @@ -6212,25 +6321,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::>().join("\n") + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&first.program).lines().map(String::from).collect::>()); } /// `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 873c607b90..1e04f6e100 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -1357,8 +1357,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. + /// 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 }; @@ -1373,19 +1374,22 @@ 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)))); + ps::push_nums(s, &ai_ops::rgb_values(color).map(f64::from)); let _ = writeln!(s, "{}", op("Xa", "XA")); } } } /// 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 { @@ -1481,7 +1485,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 @@ -1489,7 +1494,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..0a7220faa5 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,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). + /// 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; @@ -380,11 +384,12 @@ impl Writer<'_> { && let Paint::Solid { color: full, swatch: None, .. } = sw.paint { if sw.spot { + 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(' '); @@ -406,9 +411,10 @@ 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)))); + 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 7707f4bc45..3b2bdd88d8 100644 --- a/crates/eps/src/ai_ops.rs +++ b/crates/eps/src/ai_ops.rs @@ -103,6 +103,27 @@ 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 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; 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/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, + /// The `cshow` procedure running over type in a CIDFont only named, with the font it sees as + /// current (see `text`). + pub cshow: Option, pub g: GState, pub saved: Vec, 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/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/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 { "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 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::(), "日本"); + 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::>(), ["é"]); } @@ -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/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/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> { + 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> { + 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 { + 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) -> 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 { 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| String::from_utf8(bytes).unwrap().lines().filter(|l| l.ends_with("%_Bs")).map(String::from).collect::>(); + 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) -> 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) { 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(); + // 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 = 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/crates/tools/src/blendwidget.rs b/crates/tools/src/blendwidget.rs new file mode 100644 index 0000000000..af1194d78b --- /dev/null +++ b/crates/tools/src/blendwidget.rs @@ -0,0 +1,568 @@ +//! 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 }; + // 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 stepped = (self.value_now(cx) + if more { by } else { -by }).clamp(lo, hi); + self.keys = stepped - self.dragged(cx); + 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"); + } + + /// 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/lib.rs b/crates/tools/src/lib.rs index 54a4d131bd..d73cd2297f 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 2f177d5847..17bf7eeaef 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::{ @@ -131,6 +132,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. @@ -757,15 +760,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, Move All or Select/Move 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, Move All or Select/Move 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 { @@ -816,10 +823,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, } @@ -850,6 +870,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(), @@ -1024,6 +1045,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); @@ -1122,6 +1152,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)), @@ -1176,7 +1207,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(_) | State::Member(_) => return Cursor::Move, @@ -1199,6 +1230,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; } @@ -1518,6 +1552,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![]; } @@ -1557,7 +1596,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, Move All and Select/Move widgets and type on a path's brackets, then the + // 1. Live Corners widgets, pie widgets, blend widgets, the Rotate All, Space, Move All and Select/Move 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); @@ -1567,6 +1606,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/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/canvas.rs b/crates/ui-egui/src/canvas.rs index 202a0542a8..781ca2c2f6 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}; @@ -1207,6 +1207,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 9551c45954..7eb31f7d01 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -250,6 +250,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 Select/Move, 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/control.rs b/crates/ui-egui/src/control.rs index 68729b861c..32d680b549 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,10 +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(), - }); - let ids = unreadable_library_ids(app); - hide_library_ids(&mut inspected, &ids); - inspected + }) } /// The fields of the UI state, by their keys in `ui.inspect`'s `ui`, that hold panel ids: the panel @@ -322,33 +319,150 @@ const PANEL_FIELDS: [&str; 7] = /// The ids of the loaded libraries (`loaded/`) that the UI state refers to and whose file the /// request may not read, each with the id a reply shows instead: `loaded/`, 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::loaded/`) 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 -/// 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(); +/// and the Color Guide's limit, by a panel id (`library::loaded/`) in the fields of +/// [`PANEL_FIELDS`], the workspaces' layouts and the keys of shortcut overrides (`panel:`, +/// 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 +/// ([`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); } - 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); + // A shortcut for showing a panel is keyed `panel:`, 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_panel_ids(&json!(app.ui.transient_panel), &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 rest); + } + } + 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 refers 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, sent: Vec::new() } +} + +/// 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 begins one of them stays ([`begins_known_id`]). + readable: std::collections::BTreeSet, + /// 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 { + /// 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 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 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()); + 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<(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); + } + } + for pair in pairs { + if !self.sent.iter().any(|(id, _)| *id == pair.0) { + self.sent.push(pair); + } + } + } +} + +/// The paths outside the read roots that `v`'s strings and keys name at any depth, written as paths +/// ([`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/`) with `loaded/` 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/ (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)>) { + 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/…`). + let path = match file_url_path(s) { + Some(Some(path)) => path, + Some(None) => percent_decoded(&s[5..]), + None => s.to_string(), + }; + 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); + } + } + match v { + 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, false, out); + collect_sent_paths(item, vectorcraft_engine::file_access::is_path_key(key), out); + } + } + _ => {} + } } /// Adds `id` to `ids` when it is a loaded library's id (`loaded/`). @@ -379,26 +493,131 @@ fn collect_library_panel_ids(v: &Value, ids: &mut std::collections::BTreeSet`) and in keys. -fn hide_library_ids(v: &mut Value, ids: &[(String, String)]) { - if ids.is_empty() { +/// 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/`), status text, a dialog, the command palette query, a script's +/// 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); + if pairs.iter().all(Vec::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); + replace_in_value(v, &Rewrite { pairs, hiding, fold: cfg!(windows) }); +} + +/// 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, 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![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. +fn absolute_form(path: &str) -> Option { + 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 ids { + 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)) { + // 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)); + } + } } } - }; + } + pairs.sort_by_key(|(from, _)| std::cmp::Reverse(from.len())); + pairs +} + +/// 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>, + hiding: &'a Hiding, + fold: bool, +} + +/// 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 { + 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. 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) => hide(s), - Value::Array(items) => items.iter_mut().for_each(|item| hide_library_ids(item, ids)), + Value::String(s) => { + if let Some(replaced) = replace_text(s, r) { + *s = replaced; + } + } + Value::Array(items) => items.iter_mut().for_each(|item| replace_in_value(item, r)), Value::Object(fields) => { - for (mut key, mut item) in std::mem::take(fields) { - hide(&mut key); - hide_library_ids(&mut item, ids); + // 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); + 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); } } @@ -406,6 +625,275 @@ fn hide_library_ids(v: &mut Value, ids: &[(String, 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 { + 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 +/// 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, 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 { + 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 }) { + 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); + } + } + } + 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(s.len()); + let mut copied = 0; + for (start, stop, to) in cuts { + out.push_str(&s[copied..start]); + out.push_str(to); + copied = stop; + } + 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 => { + // A `file:` URL of the path is replaced whole, so no `file://` 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)); + } + } + } + } +} + +/// 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)>)> { + 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)) +} + +/// 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 +/// `/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 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 `.`, +/// `-` 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, '_' | '/' | '\\')) +} + +/// 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`])? 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 (`.bak`, `/../x`) is still hidden. +fn begins_known_id(text: &str, at: usize, end: usize, r: &Rewrite) -> Option { + 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)).find_map(|known| { + let id = if prefixed { known.as_str() } else { known.strip_prefix("loaded/").unwrap_or(known) }; + 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()) + }) +} + +/// 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; + } + match v { + Value::String(s) => { + if let Some(swapped) = swap_whole_ids(s, pairs) { + *s = swapped; + } + } + Value::Array(items) => items.iter_mut().for_each(|item| swap_library_ids(item, pairs)), + Value::Object(fields) => { + 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 { + let mut out: Option = 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"]; @@ -414,14 +902,146 @@ 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 +/// 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) => !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) + } + } +} + +/// 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) { + 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) -> 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`. +fn file_url_path(s: &str) -> Option> { + 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`). +fn steps_mut(set: &mut Value) -> impl Iterator> { + 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: 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, as a key too, a +/// string written as a path ([`unreadable_path_like`]). +fn leave_out_unreadable_params(params: &mut serde_json::Map) { + use vectorcraft_engine::file_access::is_path_key; + 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 + && 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, 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_path_like)); + 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) { if let Some(Value::Array(files)) = ui.get_mut("recent_files") { files.retain(|f| f.as_str().is_some_and(readable)); @@ -433,6 +1053,9 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { } } } + 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); } @@ -444,12 +1067,14 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map) { } } -/// 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) { 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) && readable(path)) { continue; } prefs.remove(key); @@ -494,7 +1119,214 @@ fn packet_mods(value: Option<&Value>) -> Result, 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}`, `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. +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 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 +/// reply also shows each path outside the read root that the request sent by its file name, whether it +/// 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. 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); + } + // 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 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 }; + hide_in_reply(app, &mut reply, &req.params, before); + Outcome::Done(reply) +} + +/// 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`]). 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) { + 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 +/// 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()); + } +} + +/// 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(['/', '\\', '%']); + 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, + } +} + +/// 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) { + 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() { @@ -511,12 +1343,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques "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)] + "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 1763fd679a..5797b0ddb9 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -12,6 +12,400 @@ 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 = 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:?}")}); + 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}"); +} + +/// 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(), sent: Vec::new() } +} + +/// [`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(reply, hidden)); + let _ = std::fs::remove_dir_all(root); +} + +/// 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"}, + "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}"); + 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}"); + 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 +/// 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}"); + // 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 +/// 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")], &[]); + 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); + let rewrite = super::Rewrite { pairs, hiding: &hidden, fold: true }; + super::replace_in_value(&mut reply, &rewrite); + 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"}); + 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), 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"], "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"], "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}"); +} + +/// 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] +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 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}"); +} + +/// 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] +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 +/// 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())); + 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", "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"), + 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 +/// 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); +} + +/// 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 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 +/// 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:?}"); +} + +/// 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 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 +/// 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/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, /// 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; 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) -> 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, 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, 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, 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 = ["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/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index fc732e0fb8..1efdddecd2 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2004,6 +2004,7 @@ font-technology Variable Променливи Move Slices Преместване на резени Move Spine Point Преместване на точка от оста Reshape Spine Segment Промяна на формата на сегмент от оста + Blend Easing Плавност на преливането Move Brush Преместване на четка Move Swatch Преместване на образец Move Up Преместване нагоре @@ -6071,6 +6072,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 a38e00a46e..85636a3921 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3558,6 +3558,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 @@ -6124,6 +6125,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 2798bb8269..8ba6678d31 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 @@ -3606,6 +3611,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 eab2051b53..a4450cafb0 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3612,6 +3612,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ě @@ -6339,6 +6340,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 dc249312bb..b492e5d928 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -3345,6 +3345,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 @@ -6088,6 +6089,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 dce97eb773..2f0f1f9aeb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2080,6 +2080,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 @@ -5930,6 +5931,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 92bd706277..6335069950 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -2916,6 +2916,7 @@ theme Appearance Εμφάνιση Move Slices Μετακίνηση τομών Move Spine Point Μετακίνηση σημείου κεντρικής καμπύλης Reshape Spine Segment Αναδιαμόρφωση τμήματος κεντρικής καμπύλης + Blend Easing Επιτάχυνση ανάμειξης Move Brush Μετακίνηση πινέλου Move Swatch Μετακίνηση δείγματος χρώματος Move Up Μετακίνηση επάνω @@ -6290,6 +6291,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 e17fe3f5cf..bed78b3ee5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2074,6 +2074,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 @@ -6281,6 +6282,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 cf7d463940..a1976f56ce 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3550,6 +3550,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 @@ -6369,6 +6370,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 6792c93196..3f304ec483 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2068,6 +2068,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 @@ -6334,6 +6335,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 9b62f655c5..915717a6df 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 डैश ट्रेल @@ -3938,6 +3943,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 1d67b86152..9f9b597a05 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3460,6 +3460,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 @@ -6322,6 +6323,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 dc12cc97ea..4995df7c90 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3345,6 +3345,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 @@ -6088,6 +6089,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 6a9d82cca9..8143868af8 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2112,6 +2112,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 @@ -5964,6 +5965,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 7bc895d1ba..383bc86344 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2330,6 +2330,7 @@ antiAlias Sharp シャープ Move Slices スライスを移動 Move Spine Point スパインポイントを移動 Reshape Spine Segment スパインセグメントをシェイプ変形 + Blend Easing ブレンドのイージング Move Brush ブラシを移動 Move Swatch スウォッチを移動 Move Up 上へ移動 @@ -6029,6 +6030,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 e2a57f9fce..208f189359 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -3191,6 +3191,7 @@ theme Appearance იერსახე Move Mesh Points ბადის წერტილების გადაადგილება Move Spine Point ხერხემლის წერტილის გადაადგილება Reshape Spine Segment ხერხემლის სეგმენტის ფორმის შეცვლა + Blend Easing გადასვლის შერბილება Reset with Mesh ბადით ჩამოყრა Reset with Warp გაღუნვით ხელახლა დაყენება Set Mesh Point Color ბადის წერტილის ფერის დაყენება @@ -6290,6 +6291,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 e4ec2135fe..47350334f1 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2320,6 +2320,7 @@ antiAlias Sharp 날카롭게 Move Slices 슬라이스 이동 Move Spine Point 스파인 포인트 이동 Reshape Spine Segment 스파인 세그먼트 모양 변경 + Blend Easing 블렌드 이징 Move Brush 브러시 이동 Move Swatch 견본 이동 Move Up 위로 이동 @@ -5925,6 +5926,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 25e5fdec84..87aee621d8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3359,6 +3359,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 @@ -6139,6 +6140,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 0fc86b13fc..caf11409ef 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3491,6 +3491,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 @@ -6193,6 +6194,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 7bc6eb71c4..9522379eca 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1758,6 +1758,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 @@ -6254,6 +6255,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 e4c909a6b0..a66866cdb4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2101,6 +2101,7 @@ file-filter Templates Шаблоны Move Slices Переместить фрагменты Move Spine Point Переместить точку остова Reshape Spine Segment Изменить форму сегмента остова + Blend Easing Сглаживание перехода Move Brush Переместить кисть Move Swatch Переместить образец Move Up Переместить вверх @@ -5950,6 +5951,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 2763677fe3..79fe268138 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2015,6 +2015,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 @@ -6356,6 +6357,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 3280a41b0b..1f49f5433f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1904,6 +1904,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şı… @@ -6295,6 +6296,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 c76ed23582..3b2ca93b65 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2106,6 +2106,7 @@ file-filter Templates Шаблони Move Slices Перемістити фрагменти Move Spine Point Перемістити точку осі Reshape Spine Segment Змінити форму сегмента осі + Blend Easing Згладжування переходу Move Brush Перемістити пензель Move Swatch Перемістити зразок Move Up Перемістити вгору @@ -5944,6 +5945,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 016bc2f907..76993c520d 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3229,6 +3229,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 @@ -5945,6 +5946,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 88fbe03374..20cad5226f 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1968,6 +1968,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 混合对象没有脊线 @@ -6177,6 +6178,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 8418bd45e2..674ae23ab2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3060,6 +3060,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 漸變物件沒有脊線 @@ -5982,6 +5983,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.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 { 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, target: Option, after: bool, with: Option }, - /// 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, replace: bool }, /// Art dragged off the canvas becomes a pattern swatch made of a copy of it (the art stays). Pattern { ids: Vec }, @@ -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::(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 { @@ -553,6 +566,124 @@ fn a_proxy_dropped_on_the_panel_becomes_a_swatch() { assert!(egui::DragAndDrop::payload::(&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::>(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 { + 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::(&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/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 157b58d3cb..263342741a 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!("bristle-brushes"), json!("pattern-borders"), diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f0ff34ef64..db262870ab 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -787,8 +787,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 e002d07adf..932dd9e0f3 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -3032,6 +3032,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/crates/ui-egui/src/tests/automation.rs b/crates/ui-egui/src/tests/automation.rs index 3a84e0b94d..43072d4a6e 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. @@ -446,6 +485,366 @@ fn ui_inspect_leaves_out_paths_outside_the_read_root() { 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` 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. +#[test] +fn ui_inspect_hides_a_hidden_library_path_in_every_string() { + let (mut app, tx, base) = confined("inspect-status-id"); + let path = s(base.join("outside/b.svg")); + let theirs = format!("loaded/{path}"); + app.ui.color_guide_limit = 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"], "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) 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"); + 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"], "loaded/b.svg", "{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/ (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); +} + +/// 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_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"] { + 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("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); +} + +/// 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, 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"); + 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/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}"); + 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", "{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 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 alone. +#[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 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); +} + /// 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. @@ -473,6 +872,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 { serde_json::from_value(json!({"floating": null, "anchor": null, "placement": {"Tab": {"before": null}}, "size": null, "stack": null})).unwrap() @@ -611,6 +1047,103 @@ 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 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"); + 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)]; + 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(); + 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!(["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); +} + +/// 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}"); + // 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}"); + 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 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/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, + 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| { + 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/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, 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/file-format-parity.md b/docs/file-format-parity.md index 93ee499746..3174ad7afc 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-12 · **Change:** minor (PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: 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; PDF import: Indic type keeps its marks on their line, and a line whose type would not draw as the file does keeps the file's glyphs as outlines, #5235; previously: 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. @@ -316,6 +319,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-12 | minor | PDF import (#5235): a glyph with no advance in a font the file writes horizontally (a vowel sign or anusvara set over its letter) stays on its line instead of opening as vertical type of its own. A line of type in an Indic script (U+0900 to U+0DFF) is set again after it is read and compared with the glyphs the file drew: when it would draw other glyphs or put them elsewhere (a vowel sign the file draws before its consonant, a conjunct the file doesn't name), the line keeps the file's glyphs as outlines, with a note. Indic type in a font that isn't installed never draws as the file does, so it keeps the file's glyphs too; other type in such a font still shows in the fallback font (#4568). Tests: `crates/pdf/src/tests/reshaped.rs`. Scores unchanged. | | 2026-11-12 | minor | Save refusal follow-up (#6146): changed format opens its Save options; unchanged format retains original options. Focused UI regressions passed, including the format-change case that failed before the fix; native panel acceptance pending. 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). | diff --git a/docs/gaps.md b/docs/gaps.md index 09c31c8ae8..2030cc96bb 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 (Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #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; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #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 @@ -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. @@ -212,6 +213,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 @@ -536,8 +538,10 @@ 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 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 @@ -634,7 +638,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 (eight original libraries generated in code: Art Ribbons, Calligraphic Nibs, Bristle Brushes (one + its own (nine original libraries generated in code: Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes (one bristle brush for each tip shape), Pattern Borders, Scatter Shapes, Game Trails, Energy Beams and Dry Media (grainy chalk, pencil, crayon and charcoal art brushes); 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 @@ -861,6 +865,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-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | | 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | | 2026-11-12 | minor | G11: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects the whole value (#6659, follow-up to #1326); short-drag and jitter-tap regressions in `tests/numfields.rs`; estimates unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index f70a35a102..ff8341fe4c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -145,24 +145,64 @@ 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`, `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, 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 roots shows as `loaded/` 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/`, - `loaded/ (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`, and the keys of shortcut overrides (`panel:` + 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`), 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/`, `loaded/ (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`, `target`, `before`, `onto`, + `above`, `below`) and in `window.panel.layout`'s `action` (`ui.set {panel: "library:swatches:loaded/"}` + 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`), 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 `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` + 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` + 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`, `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/`, numbered `(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/`), + 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` @@ -217,6 +257,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 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 @@ -1631,7 +1677,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. @@ -3682,7 +3730,7 @@ error. ## Brush libraries -Brush libraries are read-only sets of brushes: eight generated in code (Art Ribbons, Calligraphic Nibs, Bristle +Brush libraries are read-only sets of brushes: nine generated in code (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes with one bristle brush for each tip shape, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, and Dry Media with the Chalk Grain, Graphite Pencil, Wax Crayon and Charcoal Dust art brushes, id `dry-media`), 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 @@ -9105,6 +9153,24 @@ 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. +`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 +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, 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: + +```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, width profiles and the pen pressure, tilt, bearing and rotation recorded with the Paintbrush (a stroke without a diff --git a/docs/roadmap.md b/docs/roadmap.md index 4cc08b406d..2360cb7bb0 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,13 +147,13 @@ 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) | Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; 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); Save refusal continuation (#6146): ordinary Save offers Save As; unchanged format keeps options, changed format opens its Save options; cancellation/automation isolation; focused UI regressions passed, native acceptance pending; 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 (feature-valued alternates and unencoded glyphs applied in one Undo step while keeping Arabic joining, #5514), 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, eight generated libraries including Bristle Brushes and Dry Media, 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, nine generated libraries including Ink and Wash, Bristle Brushes and Dry Media, 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 | @@ -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 | M7: Glyphs panel applies feature-valued alternates and unencoded glyphs while keeping Arabic joining (#5514). Scores and estimates unchanged. | | 2026-11-12 | minor | M14: numeric fields keep the caret when a focus click is released more than 1 pt from its press; a tap within 1 pt still selects all (#6659, follow-up to #1326); estimates unchanged. | | 2026-11-12 | minor | M12: Live Paint Bucket fills faces crossed in one drag (#6373). Scores and estimates unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index dcb88d7e0d..ba90836848 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 (macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously 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; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously 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) +> +> Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > > Glyphs update (#5514): Alternates for Current Selection applies a variant's OpenType feature to that letter alone (`salt=2`) so an Arabic word stays joined; Entire Font lists unencoded glyphs. A positional variant no supported feature draws can be applied as a glyph choice (#6084). Entire Font uses the selection's font version and variable axes, and the default cell remains available after an unlisted choice even without GSUB alternates. Only Arabic presentation-form characters insert their joining base letter. Scores and estimates unchanged. > @@ -222,7 +224,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 (eight generated libraries including Bristle Brushes, Game Trails, Energy Beams and Dry Media, 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 (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, 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 (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`) | @@ -491,7 +493,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 (eight generated in code including Bristle Brushes, Game Trails, Energy Beams and Dry Media 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 (nine generated in code including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media 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, Rotate (Classic), Map Art and more complete visibility remain | 25–45 | @@ -505,7 +507,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 | @@ -543,7 +545,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 eight (Art Ribbons, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) 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 nine (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) 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 | @@ -600,6 +602,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 (). 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`. @@ -769,10 +772,11 @@ 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` and `links.relink {copy}` name the document's own file, the Scripts folder or the document's folder by its file name when it is outside the read roots, and keep the roots and the reason; `links.relink`'s copies name such a 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/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`. - - 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/` 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/ (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`. @@ -861,7 +865,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, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) 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, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) 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); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). 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`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). - 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. @@ -1333,6 +1337,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`). @@ -1527,6 +1532,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`). @@ -1716,6 +1722,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 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]`. - **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. @@ -2098,6 +2106,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-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | | 2026-11-07 | minor | Type core: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to the selected letter alone, so an Arabic word stays joined (`salt=2`; the style commands take `tag=N`), and Show › Entire Font lists the glyphs no character maps (#5514; [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)); evidence added; scores unchanged. | diff --git a/docs/type-parity.md b/docs/type-parity.md index c6a2d61a74..39f2713ac5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -34,7 +34,7 @@ against Illustrator's public documentation; **estimated** unless noted. | 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. A double-click applies the OpenType feature that draws a variant to that character alone, or uses a glyph choice when no supported feature draws it; see the Glyphs panel row below for Entire Font and the joining limits. The glyph-cell pop-up of alternates is not there yet | | Glyphs panel: alternates as OpenType feature values, unencoded glyphs | done | #5514, as [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html) describes (Show › Alternates For Current Selection; replace the selected character by double-clicking the glyph). A character-style feature takes a value (`"salt=2"`, the font's alternate number; `ss01`–`ss20`, `swsh`, `titl` and `aalt` too) in `features` and `featureChanges`, in SVG `font-feature-settings` (`"salt" 2`) and in native files as the plain string. Show › Entire Font applies an unencoded glyph the same way when it is an alternate of the selected character in its word, applies one no feature reaches (`A.salt` in a font with no `salt`) to the selected character as a glyph choice (it may not join), and dims the others with the reason in the tooltip (e.g. `yehfarsi.fina.alt2 belongs to ی (U+06CC) at the end of a word; your selection is ي (U+064A)`); an Arabic presentation form picked there inserts its letter so the word joins. Runs that differ only in their features shape as one piece of text, each run's features over its own characters, so a variant on one letter of an Arabic word keeps its neighbours' initial, medial and final forms and the letters still touch. The panel lists the alternates of the glyph the shaper makes for the selected character in its word (a medial letter shows medial variants), and a double-click applies the OpenType feature that draws the variant (`salt=N` or `ssNN`) to that character alone, in one undo step (`text.setRangeStyle`); a variant no feature reaches falls back to a replacement glyph (`text.setGlyphAlternate`), which its tooltip says may not join. Show › Entire Font also lists the glyphs no character maps, with their name or `gid N` and the feature that gives them. The text crate reads each face's `GSUB` once (alternate sets, single substitutions, the feature that reaches a glyph). Not done: alternates reached through contextual or ligature lookups, and features of the font that the style has no tag for (`cv01`–`cv99`, `hist`). Evidence: `tests::glyph_alternates` in the text crate (a fixture font with a medial teh at `salt=1` to `3`, the neighbours' forms, no gap; Kufi Murabba PIIIX when it is installed), `features::tests::feature_values_are_parsed_validated_and_shaped`, `tests::textedit::feature_values_are_set_replaced_and_saved`, the `panels::glyphs` tests. | -| 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` |