diff --git a/Cargo.lock b/Cargo.lock index 79778322f..72d591b91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2978,8 +2978,7 @@ checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" [[package]] name = "linesweeper" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a25df1b0c5432893f41bba817150e323703ee5f6d974f3a6808ac7fd7f046d18" +source = "git+https://github.com/storytold/craft-libs?rev=481f66d287b2a06845f3667a19daf35f0fe958ed#481f66d287b2a06845f3667a19daf35f0fe958ed" dependencies = [ "arrayvec", "kurbo 0.13.1", @@ -6265,6 +6264,7 @@ dependencies = [ "vectorcraft-eps", "vectorcraft-format", "vectorcraft-geom", + "vectorcraft-js", "vectorcraft-metafile", "vectorcraft-pathops", "vectorcraft-pdf", @@ -6283,6 +6283,7 @@ dependencies = [ name = "vectorcraft-eps" version = "0.21.0" dependencies = [ + "craft-pdf", "fax", "flate2", "hayro-ccitt", @@ -6332,6 +6333,14 @@ dependencies = [ "vectorcraft-testkit", ] +[[package]] +name = "vectorcraft-js" +version = "0.21.0" +dependencies = [ + "proptest", + "unicode-general-category", +] + [[package]] name = "vectorcraft-mcp" version = "0.21.0" @@ -6413,6 +6422,7 @@ dependencies = [ name = "vectorcraft-plugins" version = "0.21.0" dependencies = [ + "log", "serde", "serde_json", "sha2", diff --git a/Cargo.toml b/Cargo.toml index 924a1a68d..a3e3e9890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ vectorcraft-metafile = { path = "crates/metafile" } vectorcraft-plugins = { path = "crates/plugins" } vectorcraft-affinity = { path = "crates/affinity" } vectorcraft-scriptcheck = { path = "crates/scriptcheck" } +vectorcraft-js = { path = "crates/js" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -153,6 +154,7 @@ hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "9be9a637 hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } krilla = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } +linesweeper = { git = "https://github.com/storytold/craft-libs", rev = "481f66d287b2a06845f3667a19daf35f0fe958ed" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } diff --git a/NOTICE b/NOTICE index ea3c311a3..c5c5511ba 100644 --- a/NOTICE +++ b/NOTICE @@ -20,10 +20,15 @@ list (author, source, license) is in ASSETS.md. (Ubuntu Font Licence 1.0), Hack (MIT), Noto Emoji (SIL Open Font License 1.1) and emoji-icon-font (MIT). Their license texts ship with that crate. -Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, linesweeper, egui, eframe, …) are +Rust dependencies (vello_cpu, kurbo, peniko, skrifa, harfrust, usvg, egui, eframe, …) are fetched through crates.io under their own permissive licenses (mostly MIT and/or Apache-2.0; also BSD, ISC, Zlib, BSL-1.0 and Unicode-3.0). +linesweeper 0.5.0 (MIT OR Apache-2.0) is fetched from https://github.com/storytold/craft-libs at +revision 481f66d287b2a06845f3667a19daf35f0fe958ed. The copy in craft-libs' vendor/linesweeper is +the crates.io package with no file changed, and it keeps the package's LICENSE-MIT and +LICENSE-APACHE. + JPEG export uses the jpeg-encoder crate (MIT OR Apache-2.0, and the IJG License): this software is based in part on the work of the Independent JPEG Group. diff --git a/README.md b/README.md index 79d88841e..d71e5b23a 100644 --- a/README.md +++ b/README.md @@ -253,7 +253,7 @@ The scores are self-assessed from our code against public documentation; [target-app-parity.md](docs/target-app-parity.md#how-this-was-measured-2026-10-10) explains how far to trust them. **What's missing:** -- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes leaves out live effects and writes symbols and live objects as their art); +- reliable exchange of .ai files (layer structure in real files; the editing copy a save writes keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), leaves out other objects' effects other than geometry effects, and writes live objects as their art); - an interaction-fidelity pass covering every tool's modifiers and small behaviours; - 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials); - filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in); diff --git a/ROADMAP.md b/ROADMAP.md index 68b0b2d2a..0f9267e0e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,7 +24,7 @@ and the work list in [`docs/gaps.md`](docs/gaps.md). **Why alpha:** the core workflows exist end to end (drawing and path editing, Pathfinder, paint and appearance, type, SVG/PDF/EPS, print, export) and feature depth is ~75%, but a professional can't yet switch: Illustrator's own `.ai` files open with a wrong layer structure in real cases and are saved back with an editing copy that -leaves out live effects and writes symbols and live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the +keeps live effects only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports (see [docs/file-format-parity.md](docs/file-format-parity.md)), and writes live objects as their art, the interaction details have never been checked side by side, and some machines fail to start the app. Beta needs those closed (the beta list in [gaps.md](docs/gaps.md)). It passes the core-workflow gate: all six core workflows (path drawing and editing, paint and style, type, layers/artboards/export, print production, file exchange) work end to end on macOS with save and reopen, and only the last is partial, without diff --git a/crates/brush/src/lib.rs b/crates/brush/src/lib.rs index 093ce531a..448b2c2ba 100644 --- a/crates/brush/src/lib.rs +++ b/crates/brush/src/lib.rs @@ -475,6 +475,10 @@ pub struct PatternBrush { pub side_frame: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub side_swatch: Option, + /// Art for the corners where the path turns towards the side tile's top (`-y`); `inner_corner` + /// is the art for the corners where it turns the other way. Both are drawn as the corner that + /// joins a side tile on its right to one below it, and each corner tile is turned so that + /// those two arms meet the sides. #[serde(skip_serializing_if = "Option::is_none")] pub outer_corner: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/brush/src/tests.rs b/crates/brush/src/tests.rs index 8bcc8ba2b..11c83d97b 100644 --- a/crates/brush/src/tests.rs +++ b/crates/brush/src/tests.rs @@ -1,6 +1,6 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind, StrokeLayer}; -use vectorcraft_geom::{Affine, BezPath, Point, Rect, shapes}; +use vectorcraft_geom::{Affine, BezPath, Point, Rect, Vec2, shapes}; use super::*; @@ -299,6 +299,101 @@ fn pattern_places_corner_tiles() { } } +/// A pattern brush whose corner tiles have a 4 × 4 pt frame and, as art, a 2 × 2 pt square in the +/// top-left quarter of the frame. Corner art joins a side tile on its right to one below it, so +/// that quarter is on the outside of the turn. `inner` and `outer` say which kinds of corner have +/// art; `flips` are the brush's Flip Along and Flip Across. +fn marked_corner_brush(inner: bool, outer: bool, (flip_along, flip_across): (bool, bool)) -> Brush { + let marker = + Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, -2.0, 2.0, 0.0)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let mut b = pattern_brush(PatternFit::Stretch, false); + if let BrushKind::Pattern(p) = &mut b.kind { + let frame = Some(Rect::new(0.0, -2.0, 4.0, 2.0)); + if inner { + (p.inner_corner, p.inner_corner_frame) = (Some(marker.clone()), frame); + } + if outer { + (p.outer_corner, p.outer_corner_frame) = (Some(marker), frame); + } + (p.flip_along, p.flip_across) = (flip_along, flip_across); + } + b +} + +/// Each combination of corner art (both kinds, inner only, outer only) and of the two flips. +fn marked_corner_brushes() -> Vec<(String, Brush)> { + let mut v = vec![]; + for (inner, outer) in [(true, true), (true, false), (false, true)] { + for flips in [(false, false), (true, false), (false, true), (true, true)] { + v.push((format!("inner {inner}, outer {outer}, flips {flips:?}"), marked_corner_brush(inner, outer, flips))); + } + } + v +} + +/// The bounds of the 2 × 2 pt corner markers among `out`, turned or not. +fn marker_bounds(out: &[Node]) -> Vec { + out.iter().filter_map(Node::geometric_bounds).filter(|r| r.width() < 3.0 && r.height() < 3.0).collect() +} + +/// Corner tiles are square to the sides at a right angle and turn 90° with each turn of the path: +/// on a square drawn either way round, each corner's marker fills the quarter of the band's corner +/// outside the turn, with the brush flipped or not and with art for one kind of corner only +/// (#6110). +#[test] +fn pattern_corner_tiles_are_square_to_the_sides() { + let doc = Document::new(1.0, 1.0); + let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath(); + let mut reversed = BezPath::new(); + reversed.move_to((0.0, 0.0)); + for (x, y) in [(0.0, 100.0), (100.0, 100.0), (100.0, 0.0)] { + reversed.line_to((x, y)); + } + reversed.close_path(); + // The square turns the same way at all four corners, and the reversed square the other way, + // so one draws only inner corner tiles and the other only outer ones. + for (name, brush) in marked_corner_brushes() { + for path in [&sq, &reversed] { + let out = stroke_pieces(&doc, &brush, path, &stroke(1.0)); + let marks = marker_bounds(&out); + assert_eq!(marks.len(), 4, "{name}: one marker a corner"); + for (corner, outside) in SQUARE_CORNERS { + let want = Rect::from_points(corner, outside); + assert!( + marks + .iter() + .any(|r| near(r.x0, want.x0, 1e-6) && near(r.y0, want.y0, 1e-6) && near(r.x1, want.x1, 1e-6) && near(r.y1, want.y1, 1e-6)), + "{name}: the marker at {corner:?} fills {want:?}: {marks:?}" + ); + } + } + } +} + +/// At a corner that is not a right angle, a corner tile splits the difference between the two +/// sides: its marker lies on the line that halves the angle, outside the turn, for sharp and +/// shallow turns either way. Near a hairpin the marker moves smoothly towards the line the path +/// arrives on. +#[test] +fn pattern_corner_tiles_sit_on_the_corners_diagonal() { + let doc = Document::new(1.0, 1.0); + for (name, brush) in marked_corner_brushes() { + for turn in [-179.9_f64, -170.0, -135.0, -60.0, -45.0, 45.0, 60.0, 135.0, 170.0, 179.9] { + let (a, b) = (Vec2::new(1.0, 0.0), Vec2::new(turn.to_radians().cos(), turn.to_radians().sin())); + let mut bp = BezPath::new(); + bp.move_to((0.0, 0.0)); + bp.line_to((50.0, 0.0)); + bp.line_to(Point::new(50.0, 0.0) + b * 50.0); + let out = stroke_pieces(&doc, &brush, &bp, &stroke(1.0)); + let marks = marker_bounds(&out); + assert_eq!(marks.len(), 1, "{name}, {turn}°: one corner marker"); + let away = a - b; + let want = Point::new(50.0, 0.0) + away / away.hypot() * 2.0_f64.sqrt(); + assert!(marks[0].center().distance(want) < 1e-6, "{name}, {turn}°: the marker is at {:?}, not {want:?}", marks[0].center()); + } + } +} + /// The 10 × 4 pt side tile of [`pattern_brush`] with the given corner sources and no corner art. fn auto_corner_brush(outer: Option, inner: Option) -> Brush { let mut b = pattern_brush(PatternFit::Stretch, false); diff --git a/crates/brush/src/warp.rs b/crates/brush/src/warp.rs index c52f7f630..b34a1cf31 100644 --- a/crates/brush/src/warp.rs +++ b/crates/brush/src/warp.rs @@ -879,12 +879,16 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec }; trims.push(None); corner_half = corner_half.max(bb.width() * k / 2.0); - let bis = a + b; - let bis = if bis.hypot() < 1e-9 { a } else { bis / bis.hypot() }; - let xf = Affine::translate(t.verts[i].to_vec2()) - * Affine::rotate(bis.y.atan2(bis.x)) - * Affine::scale(k) - * Affine::translate(-bb.center().to_vec2()); + // Inner and outer corner art is the corner that joins a side tile on its right to one + // below it, so its arms point along +x and +y (as the flips leave them). The tile is + // turned so that the line halving its arms lies on the line halving the sides at the + // corner, back along the incoming side and on along the outgoing one: at a right angle + // the arms meet the side tiles, and at other angles the difference is split between + // the two sides (#6110). + let arms = Vec2::new(if pb.flip_along { -1.0 } else { 1.0 }, if pb.flip_across { -1.0 } else { 1.0 }); + let sides = b - a; + let angle = sides.y.atan2(sides.x) - arms.y.atan2(arms.x); + let xf = Affine::translate(t.verts[i].to_vec2()) * Affine::rotate(angle) * Affine::scale(k) * Affine::translate(-bb.center().to_vec2()); let mut n = art.clone(); n.transform(xf, true); out.take(Some(n)); diff --git a/crates/color/src/cms/generic.rs b/crates/color/src/cms/generic.rs index cea213cd6..95abdf2f3 100644 --- a/crates/color/src/cms/generic.rs +++ b/crates/color/src/cms/generic.rs @@ -231,6 +231,21 @@ impl GenericCmyk { } g[p] = -(0..3).map(|i| j[i][p] * r[i]).sum::(); } + // An ink at a bound whose descent direction points out of the box stays where it is; the step moves the other inks. + for p in 0..3 { + if (x[p] <= 0.0 && g[p] < 0.0) || (x[p] >= 1.0 && g[p] > 0.0) { + a[p] = [0.0; 3]; + for row in &mut a { + row[p] = 0.0; + } + a[p][p] = 1.0; + g[p] = 0.0; + } + } + // Every ink is held at a bound, or the gradient is zero: no step can lower the error. + if g == [0.0; 3] { + break; + } let mut improved = false; for _ in 0..8 { let mut al = a; diff --git a/crates/color/src/cms/tests.rs b/crates/color/src/cms/tests.rs index 99c16edf4..ecb4c1c59 100644 --- a/crates/color/src/cms/tests.rs +++ b/crates/color/src/cms/tests.rs @@ -259,6 +259,46 @@ fn generic_cmyk_black_round_trips_with_bpc() { assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}"); } +/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few +/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024 +/// in all) stays within 0.001 on every ink. +#[test] +fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() { + let c = cms(); + let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]; + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -2000i32..=2000 { + let mut rgb = pink; + rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } + } + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!(lo[0], 0.0, "{lo:?}"); +} + +/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change +/// by a few float steps: sRGB (0, 0, 0.25) with its blue stepped one `f32` at a time over 121 values +/// stays within 0.001 on every ink. +#[test] +fn generic_cmyk_separates_nearby_colors_with_inks_at_full_alike() { + let c = cms(); + let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]); + for step in -60i32..=60 { + let blue = f32::from_bits(0.25f32.to_bits().wrapping_add_signed(step)); + let ink = c.srgb_to_cmyk([0.0, 0.0, blue], Intent::RelativeColorimetric); + for i in 0..4 { + lo[i] = lo[i].min(ink[i]); + hi[i] = hi[i].max(ink[i]); + } + } + assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}"); + assert_eq!((lo[0], lo[1]), (1.0, 1.0), "{lo:?}"); +} + #[test] fn lab_colours_round_trip_through_the_cms() { let c = cms_no_bpc(); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 8bca517ee..bc44b7bf4 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -699,6 +699,34 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N /// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an /// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at /// the same place (by character) of both. +/// The run of `of` at fraction `u` of its characters. +fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> { + let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum(); + let target = (u * total as f64) as usize; + let mut seen = 0; + for r in &of.runs { + seen += r.text.chars().count(); + if seen > target { + return Some(r); + } + } + of.runs.last() +} + +/// The fraction of the characters of `runs` at the middle of each run. +fn run_middles(runs: &[crate::text::TextRun]) -> Vec { + let total: usize = runs.iter().map(|r| r.text.chars().count()).sum::().max(1); + let mut start = 0usize; + runs.iter() + .map(|r| { + let len = r.text.chars().count(); + let u = (start as f64 + len as f64 / 2.0) / total as f64; + start += len; + u + }) + .collect() +} + fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) { o.xf = lerp_affine(x.xf, y.xf, t); if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) = @@ -706,27 +734,10 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64 { *f = lerp_path(fx, fy, t); } - // The run of `of` at fraction `u` of its characters. - fn run_at(of: &TextObject, u: f64) -> Option<&crate::text::TextRun> { - let total: usize = of.runs.iter().map(|r| r.text.chars().count()).sum(); - let target = (u * total as f64) as usize; - let mut seen = 0; - for r in &of.runs { - seen += r.text.chars().count(); - if seen > target { - return Some(r); - } - } - of.runs.last() - } - let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::().max(1); - let mut start = 0usize; let tf = tc as f32; let mut resized = false; - for r in &mut o.runs { - let len = r.text.chars().count(); - let u = (start as f64 + len as f64 / 2.0) / total as f64; - start += len; + let middles = run_middles(&o.runs); + for (r, u) in o.runs.iter_mut().zip(middles) { let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue }; let (sx, sy) = (&rx.style, &ry.style); r.style.fill = lerp_paint(&sx.fill, &sy.fill, tf); @@ -752,33 +763,115 @@ pub fn lerp_node(a: &Node, b: &Node, t: f64) -> Node { // Step counts // ===================================================================================== -/// Largest channel difference (0..1) between the solid colours of two objects' top fill and stroke. -fn color_distance(a: &Node, b: &Node) -> f32 { - let mut d = 0.0f32; - let mut cmp = |x: &Paint, y: &Paint| { - let cols = |p: &Paint| -> Vec { - match p { - Paint::Solid { color, .. } => vec![*color], - Paint::Gradient(g) => g.gradient.stops.iter().map(|s| s.color).collect(), - _ => vec![], - } - }; - let (cx, cy) = (cols(x), cols(y)); - for (i, c) in cx.iter().enumerate() { - if let Some(o) = cy.get(i).or(cy.first()) { - let (p, q) = (c.to_rgb(), o.to_rgb()); - for k in 0..3 { - d = d.max((p[k] - q[k]).abs()); - } - } - } +/// Largest channel difference between two solid colors, as a fraction of the channel's range: two +/// tints of the same swatch by their tints, RGB by its 0 to 255 values over 256, CMYK and gray by +/// their percentages, and colors in different spaces (or Lab) by the difference of their RGB +/// values over 255. A solid paint against no paint, or anything that isn't solid, differs by 0: +/// [`lerp_paint`] switches such paints halfway rather than mixing them. +fn paint_difference(x: &Paint, y: &Paint) -> f64 { + let (Paint::Solid { color: p, swatch: sp, tint: tp }, Paint::Solid { color: q, swatch: sq, tint: tq }) = (x, y) else { + return 0.0; }; - cmp(&a.appearance.fill_paint(), &b.appearance.fill_paint()); - cmp(&a.appearance.stroke_paint(), &b.appearance.stroke_paint()); - d + if sp.is_some() && sp == sq && tp != tq { + return f64::from((tp - tq).abs()); + } + let most = |a: &[f32], b: &[f32]| a.iter().zip(b).fold(0.0f64, |m, (u, v)| m.max(f64::from((u - v).abs()))); + match (p, q) { + (Color::Rgb { r, g, b }, Color::Rgb { r: r2, g: g2, b: b2 }) => most(&[*r, *g, *b], &[*r2, *g2, *b2]) * 255.0 / 256.0, + (Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => most(&[*c, *m, *y, *k], &[*c2, *m2, *y2, *k2]), + (Color::Gray { k }, Color::Gray { k: k2 }) => most(&[*k], &[*k2]), + _ => most(&p.to_rgb(), &q.to_rgb()), + } } -/// Number of intermediate steps between two keys `len` apart (along the spine). +/// Largest difference (see [`paint_difference`]) between pairs of fills and strokes, or `None` +/// when any of them is a gradient or a pattern. +fn paints_difference(pairs: &[(&Paint, &Paint)]) -> Option { + let mut d = 0.0f64; + for (x, y) in pairs { + if [x, y].iter().any(|p| matches!(p, Paint::Gradient(_) | Paint::Pattern { .. })) { + return None; + } + d = d.max(paint_difference(x, y)); + } + Some(d) +} + +/// Largest channel difference (0..1, see [`paint_difference`]) between the solid colors of two +/// keys: their top fill and stroke, the runs of two text objects paired as [`lerp_text`] pairs +/// them, and the members of two groups paired as [`Lerp::new`] pairs them. `None` when any of +/// these holds a gradient or a pattern, whose steps Smooth Color counts by distance. +fn color_distance(a: &Node, b: &Node) -> Option { + let mut d = + paints_difference(&[(&a.appearance.fill_paint(), &b.appearance.fill_paint()), (&a.appearance.stroke_paint(), &b.appearance.stroke_paint())])?; + match (&a.kind, &b.kind) { + (NodeKind::Group { children: ca, clip: k1 }, NodeKind::Group { children: cb, clip: k2 }) if k1 == k2 && !(*k1 && ca.len() != cb.len()) => { + for (x, y) in ca.iter().zip(cb) { + d = d.max(color_distance(x, y)?); + } + } + (NodeKind::Text(x), NodeKind::Text(y)) => { + // The runs `lerp_text` pairs: those of `x` and of `y` at the middle of each run of either. + for u in run_middles(&x.runs).into_iter().chain(run_middles(&y.runs)) { + let (Some(rx), Some(ry)) = (run_at(x, u), run_at(y, u)) else { continue }; + d = d.max(paints_difference(&[(&rx.style.fill, &ry.style.fill), (&rx.style.stroke, &ry.style.stroke)])?); + } + } + _ => {} + } + d.is_finite().then_some(d) +} + +/// Smooth Color's steps between two keys by distance: the largest distance between their +/// corresponding visible bounding-box edges, in mean key widths (left and right edges) or heights +/// (top and bottom), rounded, less one, from 1 to 1000. Keys with no width are measured in their +/// mean height across as well, keys with no height in their mean width, and keys with neither in +/// points. +fn spatial_steps(ba: Rect, bb: Rect) -> usize { + let (w, h) = ((ba.width() + bb.width()) / 2.0, (ba.height() + bb.height()) / 2.0); + let size = |s: f64, other: f64| { + if s > 1e-9 { + s + } else if other > 1e-9 { + other + } else { + 1.0 + } + }; + let (w, h) = (size(w, h), size(h, w)); + let q = ((ba.x0 - bb.x0).abs().max((ba.x1 - bb.x1).abs()) / w).max((ba.y0 - bb.y0).abs().max((ba.y1 - bb.y1).abs()) / h); + if !q.is_finite() { + return 1; + } + ((q + 0.5).floor() - 1.0).clamp(1.0, 1000.0) as usize +} + +/// `255 × d` steps for a color difference `d` (see [`color_distance`]), a half step rounding down, +/// from 1 to 254. +fn level_steps(d: f64) -> f64 { + // The tolerance keeps a difference that lands on a half step (128 of 256 RGB values gives + // 127.5), stored as an `f32`, from rounding up. + (255.0 * d - 0.5 - 1e-4).ceil().clamp(1.0, 254.0) +} + +/// Smooth Color's steps between two keys whose colors differ by `d` (see [`color_distance`]): +/// [`level_steps`], and at most eight per point of the largest of the keys' widths and heights +/// and the distance between their centers along either axis. +fn color_steps(d: f64, ba: Rect, bb: Rect) -> usize { + let by_color = level_steps(d); + let (ca, cb) = (ba.center(), bb.center()); + let reach = [(ca.x - cb.x).abs(), (ca.y - cb.y).abs(), ba.width(), ba.height(), bb.width(), bb.height()].into_iter().fold(0.0f64, f64::max); + let by_density = (8.0 * reach + 0.5).floor().clamp(1.0, 254.0); + if !by_density.is_finite() { + return by_color as usize; + } + by_color.min(by_density) as usize +} + +/// Number of intermediate steps between two keys. Specified Distance counts along the spine, `len` +/// long between them. Smooth Color counts from the keys' colors and visible bounds, whatever the +/// spine: keys whose colors differ make [`color_steps`] ([`level_steps`] when either key has no +/// bounds), and others make [`spatial_steps`] (1 when either key has no bounds). pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> usize { match spacing { BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, @@ -787,17 +880,14 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) -> ((len / d).round() as i64 - 1).clamp(0, 1000) as usize } BlendSpacing::SmoothColor => { - let cd = color_distance(a, b); - if cd > 1.0 / 255.0 { - ((cd * 255.0 / 2.0).ceil() as usize).clamp(1, 256) - } else { - // Same colours: base the count on the distance between the objects. - let (ba, bb) = (a.geometric_bounds(), b.geometric_bounds()); - let dist = match (ba, bb) { - (Some(x), Some(y)) => (x.x0 - y.x0).abs().max((x.x1 - y.x1).abs()).max((x.y0 - y.y0).abs()).max((x.y1 - y.y1).abs()), - _ => len, - }; - ((dist / 2.0).ceil() as usize).clamp(1, 256) + let bounds = a.visual_bounds().zip(b.visual_bounds()); + match (color_distance(a, b), bounds) { + // Colors that differ by less than a thousandth of a step count as the same. + (Some(d), Some((ba, bb))) if d * 255.0 > 1e-3 => color_steps(d, ba, bb), + (Some(d), None) if d * 255.0 > 1e-3 => level_steps(d) as usize, + // Same colors, gradients and patterns: the distance between the objects. + (_, Some((ba, bb))) => spatial_steps(ba, bb), + (_, None) => 1, } } } @@ -1148,13 +1238,19 @@ fn requested_steps(keys: &[Arc], spec: &BlendSpec, rail: Option<&Rail>) -> .collect() } -/// Most steps `spacing` makes between two keys, whatever their size and distance -/// ([`blend_step_count`]'s clamps). -fn most_steps(spacing: BlendSpacing) -> usize { +/// Most steps `spacing` makes between keys `a` and `b`, whatever their size and distance +/// ([`blend_step_count`]'s clamps). A copy of a blend is moved and scaled, never recolored +/// ([`Shape::Other`]), so Smooth Color between colors that differ makes no more than their color +/// change asks for, whatever the copy's scale or rotation. By distance, a copy turned by Align to +/// Path can measure its edges differently, so it counts as up to 1000. +fn most_steps(a: &Node, b: &Node, spacing: BlendSpacing) -> usize { match spacing { BlendSpacing::Steps(n) => n.clamp(1, 1000) as usize, BlendSpacing::Distance(_) => 1000, - BlendSpacing::SmoothColor => 256, + BlendSpacing::SmoothColor => match color_distance(a, b) { + Some(d) if d * 255.0 > 1e-3 => level_steps(d) as usize, + _ => 1000, + }, } } @@ -1182,8 +1278,8 @@ struct StepCount { impl StepCount { /// Steps the blends in `n` make in all when drawn, each keeping to [`MAX_BLEND_STEPS`]. /// `copy`: `n` is part of a blend's step, a copy moved and scaled into place. A blend spaced - /// by distance (or by distance where the colours match) makes more steps scaled up, so a - /// copy counts the most its spacing can make between each pair, an upper bound for any scale. + /// by distance, or by Smooth Color, can make more steps scaled up, so a copy counts the most + /// its spacing can make between each pair ([`most_steps`]), an upper bound for any scale. fn node(&mut self, n: &Node, copy: bool) -> usize { let at = (std::ptr::from_ref(n) as usize, copy); if let Some(&known) = self.known.get(&at) { @@ -1249,7 +1345,11 @@ impl StepCount { fn most(&mut self, keys: &[Arc], spec: &BlendSpec) -> usize { let inside = self.nodes(keys, true); let each = self.steps(keys); - let demand = each.iter().map(|&c| c.saturating_mul(most_steps(spec.spacing))).fold(0, usize::saturating_add); + let most = keys.windows(2).map(|w| match w { + [a, b] => most_steps(a, b, spec.spacing), + _ => 0, + }); + let demand = each.iter().zip(most).map(|(&c, m)| c.saturating_mul(m)).fold(0, usize::saturating_add); inside.saturating_add(demand.min(MAX_BLEND_STEPS.saturating_sub(inside))) } } @@ -1634,4 +1734,180 @@ mod tests { assert_eq!(blend_steps(&keys[..50], &spec), vec![1000; 49]); assert_eq!(fit_steps(&[10, 20], &[1, 1], MAX_BLEND_STEPS), vec![0, 0]); } + + /// A key for the Smooth Color tests: a rectangle of `size` (width, height) centered on `c`, + /// with `fill`, and `stroke` at `width`. + fn smooth_key(c: (f64, f64), size: (f64, f64), fill: Paint, stroke: Paint, width: f64) -> Node { + let ((cx, cy), (w, h)) = (c, size); + let rect = Rect::new(cx - w / 2.0, cy - h / 2.0, cx + w / 2.0, cy + h / 2.0); + Node::path(NodeId(1), shapes::rectangle(rect), Appearance::basic(fill, stroke, width)) + } + + /// Smooth Color's steps between two filled `size` rectangles centered on `a` and `b`. + fn smooth(a: (f64, f64), b: (f64, f64), size: (f64, f64), fill_a: Color, fill_b: Color) -> usize { + let key = |c, fill| smooth_key(c, size, Paint::solid(fill), Paint::None, 0.0); + blend_step_count(&key(a, fill_a), &key(b, fill_b), BlendSpacing::SmoothColor, a.0.max(b.0) - a.0.min(b.0)) + } + + /// Smooth Color counts 255 steps for a full change of the channel that changes most, a half + /// step rounding down: RGB values over 256, CMYK and gray in percent, a spot color by its + /// tint, colors in different spaces by their RGB values over 255, a fill or a stroke, + /// whichever changes more. + #[test] + fn smooth_color_counts_a_step_per_level_of_color_change() { + let (a, b, size) = ((50.0, 150.0), (250.0, 150.0), (20.0, 20.0)); + let black = Color::rgb8(0, 0, 0); + for (level, steps) in [(1, 1), (2, 2), (5, 5), (64, 64), (127, 127), (128, 127), (129, 128), (200, 199), (254, 253), (255, 254)] { + assert_eq!(smooth(a, b, size, black, Color::rgb8(level, 0, 0)), steps, "black to red {level}"); + assert_eq!(smooth(a, b, size, black, Color::rgb8(level, level, level)), steps, "black to gray {level}"); + } + assert_eq!(smooth(a, b, size, Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)), 254); + assert_eq!(smooth(a, b, size, black, Color::rgb8(64, 128, 0)), 127, "the channel that changes most"); + assert_eq!(smooth(a, b, size, Color::rgb8(17, 42, 93), Color::rgb8(91, 147, 220)), 127); + for (percent, steps) in [(0.2, 1), (1.0, 3), (5.0, 13), (10.0, 25), (25.0, 64), (50.0, 127), (100.0, 254)] { + let p = percent / 100.0; + assert_eq!(smooth(a, b, size, Color::cmyk(0.0, 0.0, 0.0, 0.0), Color::cmyk(p, 0.0, 0.0, 0.0)), steps, "cyan {percent}%"); + assert_eq!(smooth(a, b, size, Color::gray(0.0), Color::gray(p)), steps, "gray {percent}%"); + } + let spot = |tint: f32| Paint::Solid { color: Color::rgb8(255, 0, 0).tinted(tint), swatch: Some("Spot".into()), tint }; + let key = |c, fill| smooth_key(c, size, fill, Paint::None, 0.0); + assert_eq!(blend_step_count(&key(a, spot(1.0)), &key(b, spot(0.5)), BlendSpacing::SmoothColor, 200.0), 127, "a tint of 50%"); + let stroked = |c, fill: Color, stroke: Color| smooth_key(c, size, Paint::solid(fill), Paint::solid(stroke), 1.0); + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + assert_eq!(blend_step_count(&stroked(a, red, black), &stroked(b, red, blue), BlendSpacing::SmoothColor, 200.0), 254, "the stroke changes"); + let outline = |c, stroke: Color| smooth_key(c, size, Paint::None, Paint::solid(stroke), 1.0); + assert_eq!(blend_step_count(&outline(a, red), &outline(b, blue), BlendSpacing::SmoothColor, 200.0), 254, "strokes alone"); + assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::cmyk(0.0, 0.0, 0.0, 0.0)), 128, "RGB against CMYK white"); + assert_eq!(smooth(a, b, size, Color::rgb8(255, 255, 127), Color::rgb8(255, 255, 255)), 127, "RGB against RGB white"); + } + + /// Smooth Color reads the colors of group members paired in order and of text runs, and + /// counts a solid paint against no paint by distance, since such a paint switches halfway + /// instead of mixing. + #[test] + fn smooth_color_reads_the_colors_inside_groups_and_text() { + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + let square = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0)); + let group = |x: f64, top: Color| Node::new(NodeId(1), NodeKind::Group { children: vec![square(x, red), square(x, top)], clip: false }); + let steps = |a: &Node, b: &Node| blend_step_count(a, b, BlendSpacing::SmoothColor, 200.0); + assert_eq!(steps(&group(50.0, red), &group(250.0, blue)), 254, "groups whose members change color"); + assert_eq!(steps(&group(50.0, red), &group(250.0, red)), 9, "groups of the same colors"); + let text = |x: f64, c: Color| { + let style = crate::CharStyle { fill: Paint::solid(c), ..Default::default() }; + Node::new(NodeId(1), NodeKind::Text(Box::new(crate::TextObject::point(Point::new(x, 150.0), "Hi", style)))) + }; + assert_eq!(steps(&text(50.0, red), &text(250.0, blue)), 254, "text whose runs change color"); + let mut two_runs = text(250.0, red); + if let NodeKind::Text(t) = &mut two_runs.kind { + let style = crate::CharStyle { fill: Paint::solid(blue), ..t.runs[0].style.clone() }; + t.runs.push(crate::text::TextRun::new("Hi", style)); + } + assert_eq!(steps(&text(50.0, red), &two_runs), 254, "a run that turns blue against one red run"); + let empty = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::None, Paint::None, 0.0); + assert_eq!(steps(&smooth_key((50.0, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0), &empty(250.0)), 9, "a fill against none"); + let nothing = |c: Color| Node::path(NodeId(1), PathData::new(vec![]), Appearance::basic(Paint::solid(c), Paint::None, 0.0)); + assert_eq!(steps(¬hing(red), ¬hing(blue)), 254, "keys without bounds whose colors differ"); + assert_eq!(steps(¬hing(red), ¬hing(red)), 1, "keys without bounds of the same color"); + } + + /// Smooth Color makes at most eight steps per point of the largest of the keys' widths and + /// heights and the distance between their centers along either axis. + #[test] + fn smooth_color_makes_at_most_eight_steps_a_point() { + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + for (dx, steps) in [(0.0, 160), (20.0, 160), (20.1, 161), (25.0, 200), (31.5, 252), (40.0, 254)] { + assert_eq!(smooth((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0), red, blue), steps, "{dx} pt apart"); + } + assert_eq!(smooth((100.0, 150.0), (100.0, 175.0), (20.0, 20.0), red, blue), 200, "25 pt apart vertically"); + assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (5.0, 5.0), red, blue), 40, "5 pt squares on each other"); + assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (25.0, 5.0), red, blue), 200, "the longer side counts"); + assert_eq!(smooth((100.0, 150.0), (100.0, 150.0), (1.0, 1.0), red, blue), 8); + } + + /// Smooth Color between keys of the same colors counts the largest distance between their + /// corresponding visible edges in mean key widths or heights, rounded, less one; so do keys + /// with gradients, whatever their colors. + #[test] + fn smooth_color_between_the_same_colors_counts_by_distance() { + let red = Color::rgb8(255, 0, 0); + let same = |a, b, size| smooth(a, b, size, red, red); + for (dx, steps) in [(25.0, 1), (50.0, 2), (100.0, 4), (200.0, 9), (400.0, 19), (800.0, 39)] { + assert_eq!(same((50.0, 150.0), (50.0 + dx, 150.0), (20.0, 20.0)), steps, "20 pt squares {dx} pt apart"); + } + for (side, steps) in [(5.0, 39), (10.0, 19), (40.0, 4), (80.0, 2)] { + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (side, side)), steps, "{side} pt squares"); + } + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (80.0, 10.0)), 2, "wide"); + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (10.0, 80.0)), 19, "tall"); + assert_eq!(same((50.0, 250.0), (250.0, 50.0), (20.0, 20.0)), 9, "diagonal"); + assert_eq!(same((50.0, 250.0), (250.0, 50.0), (80.0, 10.0)), 19, "wide and diagonal"); + assert_eq!(same((50.0, 150.0), (1050.0, 150.0), (1.0, 1.0)), 999); + assert_eq!(same((50.0, 150.0), (1052.0, 150.0), (1.0, 1.0)), 1000, "at most 1000"); + assert_eq!(same((50.0, 150.0), (50.0, 150.0), (20.0, 20.0)), 1, "at least one"); + assert_eq!(same((50.0, 150.0), (250.0, 150.0), (0.0, 20.0)), 9, "vertical lines, measured in their height"); + assert_eq!(same((50.0, 150.0), (50.0, 350.0), (20.0, 0.0)), 9, "horizontal lines, measured in their width"); + assert_eq!(same((50.0, 150.0), (60.0, 150.0), (0.0, 0.0)), 9, "points, measured in points"); + let key = |c, size, stroke: f64| smooth_key(c, size, Paint::solid(red), Paint::solid(Color::BLACK), stroke); + let wide = + blend_step_count(&key((50.0, 150.0), (10.0, 20.0), 0.0), &key((250.0, 150.0), (80.0, 20.0), 0.0), BlendSpacing::SmoothColor, 200.0); + assert_eq!(wide, 4, "keys of different widths"); + let strokes = + blend_step_count(&key((50.0, 150.0), (20.0, 20.0), 1.0), &key((250.0, 150.0), (20.0, 20.0), 10.0), BlendSpacing::SmoothColor, 200.0); + assert_eq!(strokes, 7, "strokes widen the visible edges"); + let gradient = |a: Color, b: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + Default::default(), + vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)], + )))) + }; + let shaded = |c, fill| smooth_key(c, (20.0, 20.0), fill, Paint::None, 0.0); + let blue = Color::rgb8(0, 0, 255); + for (from, to) in [(gradient(red, blue), gradient(red, blue)), (gradient(red, blue), gradient(blue, red))] { + assert_eq!(blend_step_count(&shaded((50.0, 150.0), from), &shaded((250.0, 150.0), to), BlendSpacing::SmoothColor, 200.0), 9); + } + } + + /// A Smooth Color blend counts each pair of keys on its own. + #[test] + fn smooth_color_counts_each_pair_of_keys() { + let key = |x: f64, c: Color| Arc::new(smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0)); + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + let spec = BlendSpec::default(); + assert_eq!(blend_steps(&[key(50.0, red), key(150.0, red), key(250.0, blue)], &spec), vec![4, 254]); + assert_eq!(blend_steps(&[key(50.0, red), key(150.0, blue), key(250.0, red)], &spec), vec![254, 254]); + } + + /// A Smooth Color blend between different colors inside another blend's keys counts as the + /// steps its colors ask for, so the outer blend keeps the steps it asks for within + /// [`MAX_BLEND_STEPS`]: 200 steps, each a copy making 254 more. The copies keep their keys' + /// colors, also halfway between a red-to-blue and a blue-to-red blend. + #[test] + fn a_smooth_color_blend_in_a_key_counts_its_color_steps() { + let (red, blue) = (Color::rgb8(255, 0, 0), Color::rgb8(0, 0, 255)); + let key = |x: f64, c: Color| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(c), Paint::None, 0.0); + let inner = |x: f64, from: Color, to: Color| blend_of(key(x, from), key(x + 100.0, to), BlendSpacing::SmoothColor); + let outer = blend_of(inner(0.0, red, blue), inner(500.0, red, blue), BlendSpacing::Steps(200)); + let (keys, spec) = blend_parts(&outer).unwrap(); + assert_eq!(blend_steps(keys, spec), vec![200]); + let crossed = blend_of(inner(0.0, red, blue), inner(500.0, blue, red), BlendSpacing::Steps(199)); + let (keys, spec) = blend_parts(&crossed).unwrap(); + assert_eq!(blend_steps(keys, spec), vec![199]); + let drawn = blend_expand(keys, spec); + let Some(NodeKind::Blend { children, .. }) = drawn.get(100).map(|n| &n.kind) else { panic!("a copy of a blend halfway") }; + let fills: Vec<_> = children.iter().map(|k| k.appearance.fill_paint().color()).collect(); + assert!(fills == [Some(red), Some(blue)] || fills == [Some(blue), Some(red)], "{fills:?}"); + } + + /// A Smooth Color blend between the same colors inside another blend's keys counts as up to + /// 1000 steps, which a copy turned to a path can ask for: 200 steps of copies of it come + /// down to 99. + #[test] + fn a_smooth_color_blend_of_the_same_colors_in_a_key_counts_up_to_1000_steps() { + let red = Color::rgb8(255, 0, 0); + let key = |x: f64| smooth_key((x, 150.0), (20.0, 20.0), Paint::solid(red), Paint::None, 0.0); + let inner = |x: f64| blend_of(key(x), key(x + 100.0), BlendSpacing::SmoothColor); + let outer = blend_of(inner(0.0), inner(500.0), BlendSpacing::Steps(200)); + let (keys, spec) = blend_parts(&outer).unwrap(); + assert_eq!(blend_steps(keys, spec), vec![99]); + } } diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index 781e61e68..ffa9eac0c 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -415,6 +415,68 @@ impl Document { } } + /// [`Self::update_links`] for the links `pick` holds for only, in one pass; only the nodes on the + /// way to one are copied. + pub fn update_links_where(&mut self, pick: impl Fn(&LinkInfo) -> bool, mut f: impl FnMut(&mut LinkInfo)) { + fn link_of(n: &Node) -> Option<&LinkInfo> { + match &n.kind { + NodeKind::Image(image) => image.link.as_ref(), + NodeKind::PlacedDocument(placed) => Some(&placed.link), + _ => None, + } + } + /// `n` updated in place, a copy of it when shared; left as it is when it holds no picked link. + fn update(n: &mut Arc, pick: &dyn Fn(&LinkInfo) -> bool, f: &mut dyn FnMut(&mut LinkInfo)) { + // Each child is updated through a second handle, so an updated child is a new node and a new + // pointer marks a change. + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + update(&mut art, pick, f); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut children = vec![]; + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut new = Arc::clone(c); + update(&mut new, pick, f); + if !Arc::ptr_eq(&new, c) { + children.push((i, new)); + } + } + let own = link_of(n).is_some_and(pick); + if !own && mask.is_none() && children.is_empty() { + return; + } + let n = Arc::make_mut(n); + let link = match &mut n.kind { + NodeKind::Image(image) => image.link.as_mut(), + NodeKind::PlacedDocument(placed) => Some(&mut placed.link), + _ => None, + }; + if let Some(link) = link.filter(|_| own) { + f(link); + } + if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(slots) = n.children_mut() { + for (i, c) in children { + if let Some(slot) = slots.get_mut(i) { + *slot = c; + } + } + } + } + let roots = self + .layers + .iter_mut() + .chain(self.symbols.iter_mut().map(|s| &mut s.art)) + .chain(self.graph_designs.iter_mut().map(|g| &mut g.art)) + .chain(self.patterns.iter_mut().flat_map(|p| &mut p.art)); + for root in roots { + update(root, &pick, &mut f); + } + } + /// Change every image object (in the layers, symbol definitions and pattern swatches, opacity-mask /// art included) for which `pick` holds; only the subtrees holding one are copied. pub fn update_images(&mut self, pick: impl Fn(&ImageObject) -> bool, mut f: impl FnMut(&mut ImageObject)) { @@ -486,6 +548,40 @@ mod tests { .unwrap() } + /// `update_links_where` changes only the picked links, in a copy of the nodes on the way to them; + /// the other nodes stay shared with the document it was cloned from. + #[test] + fn update_links_where_copies_only_the_nodes_it_changes() { + let mut d = Document::new(10.0, 10.0); + image(&mut d, "a", Some(LinkInfo::new("/art/a.png"))); + image(&mut d, "b", Some(LinkInfo::new("/art/b.png"))); + image(&mut d, "c", None); + let mut copy = d.clone(); + let mut calls = 0; + copy.update_links_where( + |l| l.path == "/art/a.png", + |l| { + calls += 1; + l.relative = Some("art/a.png".into()); + }, + ); + assert_eq!(calls, 1); + let node = |d: &Document, key: &str| { + d.layers[0].children().unwrap().iter().find(|n| matches!(&n.kind, NodeKind::Image(im) if im.key == key)).unwrap().clone() + }; + let relative = |n: &Node| match &n.kind { + NodeKind::Image(im) => im.link.as_ref().and_then(|l| l.relative.clone()), + _ => None, + }; + assert_eq!((relative(&node(©, "a")), relative(&node(&d, "a"))), (Some("art/a.png".into()), None)); + assert!(Arc::ptr_eq(&node(©, "b"), &node(&d, "b")) && Arc::ptr_eq(&node(©, "c"), &node(&d, "c"))); + assert!(!Arc::ptr_eq(©.layers[0], &d.layers[0])); + // Nothing picked: nothing is copied. + let mut same = d.clone(); + same.update_links_where(|_| false, |_| {}); + assert!(Arc::ptr_eq(&same.layers[0], &d.layers[0])); + } + #[test] fn the_placeholder_is_a_png() { let png = missing_placeholder().unwrap(); diff --git a/crates/doc/src/live.rs b/crates/doc/src/live.rs index 40a1c78b0..508ce247f 100644 --- a/crates/doc/src/live.rs +++ b/crates/doc/src/live.rs @@ -53,8 +53,9 @@ pub use blend_spec::{BlendDefaults, BlendEase, BlendEaseType, BlendOrientation, #[cfg(test)] use blend_spec::{blend_color_shift, blend_ramp}; pub use envelope::{ - EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, envelope_bounds, envelope_content_boxes, - envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, smooth_handles, + EnvelopeKind, EnvelopeMap, EnvelopeOptions, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, TypeInk, envelope_bounds, envelope_content_bounds, + envelope_content_boxes, envelope_expand, envelope_frame, envelope_grid, envelope_mapper, envelope_of, envelope_overlay, envelope_stroke_margin, + set_type_ink, smooth_handles, }; pub(crate) use expand::max_outset; pub use expand::{ diff --git a/crates/doc/src/live/envelope.rs b/crates/doc/src/live/envelope.rs index 78e1a5bf0..a58ae9c27 100644 --- a/crates/doc/src/live/envelope.rs +++ b/crates/doc/src/live/envelope.rs @@ -1,6 +1,7 @@ use super::expand::{Hooks, Outliner, Warper, map_node}; -use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, nodes_bounds, surface_of}; +use super::mesh::{GradientMesh, MeshPoint, grid_len, grid_points, surface_of}; use super::{Affine, Arc, Color, Deserialize, Node, NodeId, NodeKind, PathData, Point, Rect, Serialize, Vec2, yes}; +use vectorcraft_geom::Homography; /// How an envelope distorts its content. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -127,19 +128,293 @@ impl EnvelopeOptions { } } -/// The bounds of `content` in the axes of `frame`, with its strokes and geometric. Strokes scale -/// with the frame as [`Node::transform`] scales them. +/// The bounds of `content` in the axes of `frame`, with its strokes and geometric, type measured by +/// its glyph outlines (see [`envelope_content_bounds`]). Strokes scale with the frame as +/// [`Node::transform`] scales them. pub fn envelope_content_boxes(content: &[Arc], frame: Affine) -> Option<(Rect, Rect)> { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); let (stroked, plain) = content.iter().fold((None, None), |(stroked, plain), c| { - let mut m = (**c).clone(); - m.transform(to_frame, true); - (vectorcraft_geom::union_opt(stroked, m.visual_bounds()), vectorcraft_geom::union_opt(plain, m.geometric_bounds())) + (vectorcraft_geom::union_opt(stroked, content_box(c, true, to_frame)), vectorcraft_geom::union_opt(plain, content_box(c, false, to_frame))) }); Some((stroked?, plain?)) } +/// The geometric bounds an envelope around `content` on the page axes maps from: type, alone or +/// in groups and layers, is measured by its glyph outlines, underlines and strikethroughs instead +/// of its laid-out box. Type keeps its laid-out box while nothing is registered with +/// [`set_type_ink`], and when it draws nothing. +pub fn envelope_content_bounds(content: &[Arc]) -> Option { + content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, Affine::IDENTITY))) +} + +/// The box of type's glyph outlines, underlines and strikethroughs with its text space mapped by +/// the given transform (outlines mapped point by point) and, for type in perspective, then drawn +/// through its projection and mapped by the second transform. `None` when it draws nothing. Type is +/// laid out above this crate, so the text crate registers one with [`set_type_ink`]. +pub type TypeInk = fn(&crate::TextObject, Affine, Option<(Homography, Affine)>) -> Option; + +/// What [`TypeInk`] maps type's text space by: a transform, and a projection with the transform +/// after it. +type TypeMap = (Affine, Option<(Homography, Affine)>); + +static TYPE_INK: std::sync::OnceLock<(TypeInk, crate::hit::LiveInputs)> = std::sync::OnceLock::new(); + +/// Register how envelopes measure type, and a number that changes whenever what it reads besides +/// the type changes, such as the installed fonts (the first registration stays). +pub fn set_type_ink(f: TypeInk, inputs: crate::hit::LiveInputs) { + // Already set: the same measure, registered by another session, an import or a renderer. + let _ = TYPE_INK.set((f, inputs)); +} + +/// How many entries each table of [`TypeInks`] holds before it is emptied. +const TYPE_INK_KEPT: usize = 1024; + +/// `map`, bit for bit, for a table key. +fn map_bits(map: &TypeMap) -> [u64; 21] { + let mut bits = [0; 21]; + let (m, projection) = map; + for (b, v) in bits.iter_mut().zip(m.as_coeffs()) { + *b = v.to_bits(); + } + if let Some((h, after)) = projection { + for (b, v) in bits[6..].iter_mut().zip(h.to_array().into_iter().chain(after.as_coeffs())) { + *b = v.to_bits(); + } + } + bits +} + +/// A key of [`TypeInks::nodes`]: the object's address, the map its outlines are measured in (an +/// affine map without its move) and the inputs number registered with [`set_type_ink`]. +type NodeKey = (usize, [u64; 21], u64); + +/// A key of [`TypeInks::content`]: two 64-bit hashes of the type without its transform's move and +/// its layout caches ([`content_hash`]), the map its outlines are measured in, and the inputs number. +type ContentKey = ([u64; 2], [u64; 21], u64); + +/// Type measured by [`type_ink`] on one thread, by the map it is measured in and the inputs +/// number: by the object (an unchanged object is found without being read), +/// and by its content (a moved or copied object is found without laying it out again). Neither +/// table keeps a copy of the type. +#[derive(Default)] +struct TypeInks { + /// By the object's address, with a weak pointer that keeps the address from being reused. + nodes: std::collections::HashMap, Option)>, + /// By the type's content. + content: std::collections::HashMap>, +} + +/// Where [`TypeInks::find`] did not find type: its keys in both tables (no content key for type +/// that could not be hashed whole). +struct Missing { + by_node: NodeKey, + content: Option, +} + +impl TypeInks { + /// The kept box of type node `n` (holding `t`) mapped by `map`, or the keys to keep it under + /// once it is measured. + fn find(&mut self, n: &Arc, t: &crate::TextObject, map: &TypeMap, inputs: u64) -> Result, Box> { + // While the table holds a weak pointer to `n`'s allocation, no other object can be at that + // address, and `n` can't be changed in place (`Arc::get_mut` refuses it and `Arc::make_mut` + // moves it). + let by_node = (Arc::as_ptr(n) as usize, map_bits(map), inputs); + if let Some((_, ink)) = self.nodes.get(&by_node) { + return Ok(*ink); + } + // Shrink Text to Fit lays type out by its size on the page, so only a move keeps its layout. + let [a, b, c, d, _, _] = t.xf.as_coeffs(); + let mut text = t.clone(); + text.xf = Affine::new([a, b, c, d, 0.0, 0.0]); + text.cached_bounds = None; + text.cached_baselines.clear(); + let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs)); + match content.and_then(|key| self.content.get(&key)) { + Some(ink) => { + let ink = *ink; + self.keep_node(n, by_node, ink); + Ok(ink) + } + None => Err(Box::new(Missing { by_node, content })), + } + } + + /// Keep the box `ink` measured for type node `n` under the keys `missing`. + fn keep(&mut self, n: &Arc, missing: Missing, ink: Option) { + if let Some(key) = missing.content { + if self.content.len() >= TYPE_INK_KEPT { + self.content.clear(); + } + self.content.insert(key, ink); + } + self.keep_node(n, missing.by_node, ink); + } + + fn keep_node(&mut self, n: &Arc, by_node: NodeKey, ink: Option) { + if self.nodes.len() >= TYPE_INK_KEPT { + self.nodes.retain(|_, (weak, _)| weak.strong_count() > 0); + if self.nodes.len() >= TYPE_INK_KEPT { + self.nodes.clear(); + } + } + self.nodes.insert(by_node, (Arc::downgrade(n), ink)); + } +} + +/// Two independent 64-bit hashes of `text` as saved (the layout caches are not saved), with what +/// layout reads that is not saved: whether its first paragraph continues a thread and the bounds +/// of its inline graphics. `None` when it can't be written whole. +fn content_hash(text: &crate::TextObject) -> Option<[u64; 2]> { + use std::hash::Hasher; + /// Feeds what is written to two hashers. + struct Feed([std::collections::hash_map::DefaultHasher; 2]); + impl std::io::Write for Feed { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + for h in &mut self.0 { + h.write(buf); + } + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut feed = Feed(Default::default()); + // The second hasher starts from another state. + feed.0[1].write_u8(1); + serde_json::to_writer(&mut feed, text).ok()?; + let inline_bounds = text.runs.iter().filter_map(|r| r.inline.as_ref()).map(|i| i.bounds.map(|b| [b.x0, b.y0, b.x1, b.y1].map(f64::to_bits))); + for h in &mut feed.0 { + h.write_u8(u8::from(text.first_para_continues)); + for b in inline_bounds.clone() { + match b { + None => h.write_u8(0), + Some(bits) => { + h.write_u8(1); + bits.into_iter().for_each(|v| h.write_u64(v)); + } + } + } + } + let [a, b] = feed.0; + Some([a.finish(), b.finish()]) +} + +std::thread_local! { + /// The type [`type_ink`] measured on this thread. + static TYPE_INKS: std::cell::RefCell = std::cell::RefCell::new(TypeInks::default()); +} + +/// The box of the glyph outlines of type node `n` (holding `t`) with its text space mapped by `m` +/// ([`set_type_ink`]) and, for type in perspective, drawn through `projection` and mapped by its +/// second transform; measured again when `t`, the maps (without a move of `m` when there is no +/// projection) or the registered inputs number change. `None` while nothing is registered. +fn type_ink(n: &Arc, t: &crate::TextObject, m: Affine, projection: Option<(Homography, Affine)>) -> Option> { + let (f, inputs) = TYPE_INK.get()?; + let inputs = inputs(); + // Without a projection the type is measured without the move, which is added to the box. + let [a, b, c, d, e, g] = m.as_coeffs(); + let (map, offset) = match projection { + None => ((Affine::new([a, b, c, d, 0.0, 0.0]), None), Vec2::new(e, g)), + Some(p) => ((m, Some(p)), Vec2::ZERO), + }; + let ink = match TYPE_INKS.with_borrow_mut(|kept| kept.find(n, t, &map, inputs)) { + Ok(ink) => ink, + Err(missing) => { + // Measured outside the borrow: laying type out may measure other type. + let ink = f(t, map.0, map.1); + TYPE_INKS.with_borrow_mut(|kept| kept.keep(n, *missing, ink)); + ink + } + }; + Some(ink.map(|r| r + offset)) +} + +/// Add to `typed` the address of `n` and of each group and layer in it that holds type, which +/// [`content_box`] measures by its glyphs; is there type in `n`? One walk of the tree. +fn find_type(n: &Node, typed: &mut std::collections::HashSet) -> bool { + let holds = match &n.kind { + NodeKind::Text(_) => true, + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().fold(false, |holds, c| find_type(c, typed) | holds), + _ => false, + }; + if holds { + typed.insert(n as *const Node as usize); + } + holds +} + +/// The box of `n` an envelope maps from, in the axes `to` takes the page to: its geometric bounds, +/// or with `stroked` its visual bounds (strokes scaled by `to` as [`Node::transform`] scales them), +/// with type measured by its glyph outlines ([`type_ink`]) instead of its laid-out box, in groups +/// and layers too. Type is laid out as it is on the page, and its outlines are then taken into +/// those axes. A character's strokes reach as far past its glyphs as past the laid-out box. +fn content_box(n: &Arc, stroked: bool, to: Affine) -> Option { + let mut typed = std::collections::HashSet::new(); + find_type(n, &mut typed); + content_box_in(n, stroked, to, &typed) +} + +/// [`content_box`], with the addresses of the objects in `n` that hold type ([`find_type`]). +fn content_box_in(n: &Arc, stroked: bool, to: Affine, typed: &std::collections::HashSet) -> Option { + // `n` in the axes of `to`. + let moved = || { + if to == Affine::IDENTITY { + return std::borrow::Cow::Borrowed(&**n); + } + let mut m = (**n).clone(); + m.transform(to, true); + std::borrow::Cow::Owned(m) + }; + let own = |m: &Node| if stroked { m.visual_bounds() } else { m.geometric_bounds() }; + if !typed.contains(&(Arc::as_ptr(n) as usize)) { + return own(&moved()); + } + match &n.kind { + NodeKind::Text(t) => { + // Type in perspective: its outlines as its projection draws them. + let ink = match n.projection() { + None => type_ink(n, t, to * t.xf, None), + Some(h) => type_ink(n, t, t.xf, Some((h, to))), + } + .flatten(); + let m = moved(); + let Some(ink) = ink else { return own(&m) }; + if !stroked { + return Some(ink); + } + let (Some(v), Some(g)) = (m.visual_bounds(), m.geometric_bounds()) else { return Some(ink) }; + Some(Rect::new( + ink.x0 - (g.x0 - v.x0).max(0.0), + ink.y0 - (g.y0 - v.y0).max(0.0), + ink.x1 + (v.x1 - g.x1).max(0.0), + ink.y1 + (v.y1 - g.y1).max(0.0), + )) + } + NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => { + // A clipping path that paints strokes over the clip edge, as in `Node::visual_bounds`. + let clip = children.first()?; + content_box_in(clip, stroked && matches!(clip.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_)), to, typed) + } + NodeKind::Layer { .. } | NodeKind::Group { .. } => { + let layer = matches!(n.kind, NodeKind::Layer { .. }); + let members = n.editing_children().iter(); + if stroked { + // The container's own strokes, scaled by `to` as `Node::transform` scales them, + // without copying its members. + let mut own_strokes = Node::new(n.id, NodeKind::Group { children: Vec::new(), clip: false }); + own_strokes.appearance = n.appearance.clone(); + own_strokes.transform(to, true); + own_strokes.visual_of_members(members.map(|c| content_box_in(c, true, to, typed))) + } else { + members.filter(|c| c.visible || !layer).fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box_in(c, false, to, typed))) + } + } + _ => own(&moved()), + } +} + /// The [`EnvelopeOptions::bounds_margin`] of content with bounds `stroked` and `plain`. `None` for /// content with no width or no height, or for margins an `f32` can't hold. pub fn envelope_stroke_margin(stroked: Rect, plain: Rect) -> Option<[f32; 4]> { @@ -265,20 +540,13 @@ pub struct EnvelopeMap<'a> { } impl<'a> EnvelopeMap<'a> { - /// The map of an envelope of `kind` around `content` with `frame` and `options`. `None` when - /// the content has no bounds. + /// The map of an envelope of `kind` around `content` with `frame` and `options`, from the + /// content's bounds with type measured by its glyph outlines ([`envelope_content_bounds`]). + /// `None` when the content has no bounds. pub fn new(content: &[Arc], kind: &'a EnvelopeKind, frame: Affine, options: EnvelopeOptions) -> Option { let frame = if frame.is_finite() && frame.determinant().abs() > 1e-12 { frame } else { Affine::IDENTITY }; let to_frame = frame.inverse(); - let src = if frame == Affine::IDENTITY { - nodes_bounds(content)? - } else { - content.iter().try_fold(None, |acc, c| { - let mut m = (**c).clone(); - m.transform(to_frame, false); - Some(vectorcraft_geom::union_opt(acc, m.geometric_bounds())) - })?? - }; + let src = content.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, content_box(c, false, to_frame)))?; let src = options.source_box(src); Some(Self { src, frame, to_frame, surface: surface_of(kind, src, frame) }) } @@ -378,3 +646,112 @@ pub fn envelope_bounds(content: &[Arc], kind: &EnvelopeKind, frame: Affine EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(), } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CharStyle, TextObject}; + + /// Look `n` up in `kept`, measuring it (and counting the measures) when it isn't there. + fn look_up(kept: &mut TypeInks, n: &Arc, linear: Affine, measured: &mut usize) -> Option { + let NodeKind::Text(t) = &n.kind else { panic!("type") }; + match kept.find(n, t, &(linear, None), 0) { + Ok(ink) => ink, + Err(missing) => { + *measured += 1; + let ink = Some(Rect::new(0.0, 0.0, *measured as f64, 1.0)); + kept.keep(n, *missing, ink); + ink + } + } + } + + fn type_node(i: usize, text: &str) -> Arc { + let t = TextObject::point(Point::new(i as f64 * 10.0, 0.0), text, CharStyle::default()); + Arc::new(Node::new(NodeId(i as u64), NodeKind::Text(Box::new(t)))) + } + + /// Many type objects are each measured once however often they are looked up; a moved or copied + /// object is found by its content, and a changed one or another transform is measured again. + #[test] + fn measured_type_is_kept_for_many_objects() { + let mut kept = TypeInks::default(); + let mut measured = 0; + let nodes: Vec<_> = (0..200).map(|i| type_node(i, &format!("Type {i}"))).collect(); + for _ in 0..5 { + for n in &nodes { + look_up(&mut kept, n, Affine::IDENTITY, &mut measured); + } + } + assert_eq!(measured, 200); + let mut moved = (*nodes[7]).clone(); + moved.transform(Affine::translate((300.0, 40.0)), true); + let moved = Arc::new(moved); + assert_eq!(look_up(&mut kept, &moved, Affine::IDENTITY, &mut measured), look_up(&mut kept, &nodes[7], Affine::IDENTITY, &mut measured)); + assert_eq!(measured, 200, "a moved object is found by its content"); + let changed = type_node(7, "Changed"); + look_up(&mut kept, &changed, Affine::IDENTITY, &mut measured); + assert_eq!(measured, 201, "a changed object is measured again"); + look_up(&mut kept, &nodes[7], Affine::rotate(0.5), &mut measured); + assert_eq!(measured, 202, "another transform is measured again"); + } + + /// Type that differs only in what layout reads but files don't save (a first paragraph that + /// continues a thread, the resolved bounds of an inline graphic) is measured again. + #[test] + fn type_that_differs_in_what_is_not_saved_is_measured_again() { + let mut kept = TypeInks::default(); + let mut measured = 0; + let mut t = TextObject::point(Point::ZERO, "Type", CharStyle::default()); + t.runs.push(crate::TextRun::new("", CharStyle::default())); + t.runs[1].inline = Some(crate::InlineArt { symbol: "S".into(), scale: 1.0, baseline_shift: 0.0, bounds: None }); + let node = |t: &TextObject| Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + assert_eq!(measured, 1, "a copy is found by its content"); + t.first_para_continues = true; + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + assert_eq!(measured, 2, "a first paragraph that continues a thread"); + if let Some(inline) = &mut t.runs[1].inline { + inline.bounds = Some(Rect::new(0.0, 0.0, 10.0, 20.0)); + } + look_up(&mut kept, &node(&t), Affine::IDENTITY, &mut measured); + assert_eq!(measured, 3, "an inline graphic's bounds"); + } + + /// A stroked group of type in a scaled frame reaches past its members by its own strokes scaled + /// with the frame, as the group moved into the frame does; deeply nested groups are measured in + /// one walk each. + #[test] + fn a_stroked_group_of_type_is_measured_with_its_strokes_in_the_frame() { + let text = type_node(1, "Group"); + let mut group = Node::group(NodeId(2), vec![text.clone()]); + group.appearance = crate::Appearance::basic(vectorcraft_color::Paint::None, vectorcraft_color::Paint::solid(Color::BLACK), 4.0).into(); + let group = Arc::new(group); + let to = Affine::scale(2.0); + let mut moved = (*group).clone(); + moved.transform(to, true); + let want = moved.visual_of_members(std::iter::once(content_box(&text, true, to))); + assert!(want.is_some()); + assert_eq!(content_box(&group, true, to), want); + let mut nested = group; + for i in 0..300 { + nested = Arc::new(Node::group(NodeId(10 + i), vec![nested])); + } + assert!(content_box(&nested, false, Affine::IDENTITY).is_some()); + } + + /// A table that fills up is emptied instead of growing, and what is looked up after that is + /// measured again. + #[test] + fn the_tables_stay_bounded() { + let mut kept = TypeInks::default(); + let mut measured = 0; + let nodes: Vec<_> = (0..TYPE_INK_KEPT + 10).map(|i| type_node(i, &format!("Type {i}"))).collect(); + for n in &nodes { + look_up(&mut kept, n, Affine::IDENTITY, &mut measured); + } + assert!(kept.content.len() <= TYPE_INK_KEPT && kept.nodes.len() <= TYPE_INK_KEPT, "{} {}", kept.content.len(), kept.nodes.len()); + assert_eq!(measured, TYPE_INK_KEPT + 10); + } +} diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 528e33e3e..5caada836 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -7,6 +7,7 @@ //! those links (and colours) wherever art and swatches hold them, copying only the nodes that //! change. +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use vectorcraft_color::swatch::REGISTRATION; @@ -85,6 +86,12 @@ impl Document { fn numbered_swatch_name(&self, base: &str, from: usize) -> String { (from..).map(|i| format!("{base} {i}")).find(|n| !self.swatch_name_taken(n)).unwrap_or_else(|| base.to_string()) } + /// Names for several new swatches: [`FreeSwatchNames::take`] gives what + /// [`Document::free_swatch_name`] gives once the swatches named before it are added. + pub fn free_swatch_names(&self) -> FreeSwatchNames { + let taken = self.swatches_iter().map(|s| s.name.clone()).chain(self.swatch_groups.iter().map(|g| g.name.clone())); + FreeSwatchNames { taken: taken.chain([REGISTRATION.to_string()]).collect(), next: HashMap::new() } + } /// The name a new swatch of `paint` gets by default, unused in the document: a colour's values /// ([`color_name`], made free like [`Document::free_swatch_name`]), or "New Gradient Swatch 1", /// "New Pattern Swatch 1"… @@ -200,6 +207,52 @@ pub fn color_name(c: Color) -> String { } } +/// The swatch and color group names of a document ([`Document::free_swatch_names`]) and the names +/// handed out since, for naming many new swatches without searching the document for each name. +#[derive(Clone, Debug)] +pub struct FreeSwatchNames { + taken: HashSet, + /// For each base and first number already numbered, the number its next numbered name is + /// looked for from: names are only ever added, so the numbers below it stay taken. + next: HashMap<(String, usize), usize>, +} + +impl FreeSwatchNames { + /// `base`, or the first of "base 2", "base 3"… not taken, as [`Document::free_swatch_name`] + /// gives it; the name is taken from then on. + pub fn take(&mut self, base: &str) -> String { + let name = if self.taken.contains(base) { self.numbered(base, 2) } else { base.to_string() }; + self.taken.insert(name.clone()); + name + } + /// The name [`Document::new_swatch_name`] gives a new swatch of `paint`; the name is taken + /// from then on. + pub fn take_new(&mut self, paint: &Paint) -> String { + let base = match paint { + Paint::Solid { swatch: Some(n), tint, .. } if *tint < 1.0 => return self.take(&format!("{n} {}%", tint_percent(*tint))), + Paint::Solid { color, .. } => return self.take(&color_name(*color)), + Paint::Gradient(_) => "New Gradient Swatch", + _ => "New Pattern Swatch", + }; + let name = self.numbered(base, 1); + self.taken.insert(name.clone()); + name + } + /// Count `name` as taken (a swatch or color group added another way). + pub fn insert(&mut self, name: &str) { + self.taken.insert(name.to_string()); + } + /// The first of "base `from`", "base `from + 1`"… not taken. + fn numbered(&mut self, base: &str, from: usize) -> String { + let key = (base.to_string(), from); + let start = self.next.get(&key).copied().unwrap_or(from); + let (i, name) = + (start..).map(|i| (i, format!("{base} {i}"))).find(|(_, n)| !self.taken.contains(n)).unwrap_or_else(|| (start, base.to_string())); + self.next.insert(key, i + 1); + name + } +} + /// What [`Document::map_solid_paints`] applies to each linkable colour: `(colour, link, tint)`, /// true when it changed them. pub type LinkFn<'a> = dyn FnMut(&mut Color, &mut Option, &mut f32) -> bool + 'a; @@ -419,6 +472,56 @@ mod tests { assert_eq!(d.new_swatch_name(&Paint::solid(Color::rgb(1.0, 0.0, 0.0))), "R=255 G=0 B=0"); } + #[test] + fn free_swatch_names_match_naming_each_swatch_as_it_is_added() { + let (mut d, _, _) = doc(); + let mut names = d.free_swatch_names(); + for base in ["Brand", "Brand", "Mine", "Fresh", "Fresh", "Brand 2", "Brand", REGISTRATION, "Fresh 3", "Fresh", "Mine 2"] { + let want = d.free_swatch_name(base); + assert_eq!(names.take(base), want, "{base}"); + d.swatches.push(Swatch { name: want, paint: Paint::solid(Color::BLACK), global: false, spot: false }); + } + let added: Vec<&str> = d.swatches.iter().rev().take(11).rev().map(|w| w.name.as_str()).collect(); + assert_eq!( + added, + ["Brand 2", "Brand 3", "Mine 2", "Fresh", "Fresh 2", "Brand 2 2", "Brand 4", "[Registration] 2", "Fresh 3", "Fresh 4", "Mine 2 2"] + ); + } + + #[test] + fn free_swatch_names_match_new_swatch_names_as_swatches_are_added() { + let (mut d, _, _) = doc(); + let red = Color::rgb(1.0, 0.0, 0.0); + d.swatches.push(Swatch { name: "New Gradient Swatch 2".into(), paint: Paint::solid(red), global: false, spot: false }); + let mut names = d.free_swatch_names(); + // A name taken another way (a group made alongside) counts for both. + names.insert("R=255 G=0 B=0 3"); + d.swatch_groups.push(SwatchGroup { name: "R=255 G=0 B=0 3".into(), swatches: vec![] }); + let tint = Paint::Solid { color: red, swatch: Some("Brand".into()), tint: 0.4 }; + let gradient = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(Default::default()))); + let paints = [Paint::solid(red), Paint::solid(red), gradient.clone(), tint.clone(), Paint::solid(red), gradient.clone(), tint, gradient]; + let mut added = vec![]; + for paint in paints { + let want = d.new_swatch_name(&paint); + assert_eq!(names.take_new(&paint), want); + d.swatches.push(Swatch { name: want.clone(), paint, global: false, spot: false }); + added.push(want); + } + assert_eq!( + added, + [ + "R=255 G=0 B=0", + "R=255 G=0 B=0 2", + "New Gradient Swatch 1", + "Brand 40%", + "R=255 G=0 B=0 4", + "New Gradient Swatch 3", + "Brand 40% 2", + "New Gradient Swatch 4" + ] + ); + } + #[test] fn mapping_copies_only_changed_nodes() { let (mut d, a, b) = doc(); diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index a257e2e0a..f189801bc 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -228,7 +228,8 @@ pub struct ParaStyle { pub leading_model: LeadingModel, /// Hanging punctuation (Paragraph panel menu › Burasagari): a comma or full stop ending a line /// may stand outside the frame. New type takes [`Burasagari::Standard`]; documents from before - /// it and imported text keep [`Burasagari::None`]. + /// it and imported text keep [`Burasagari::None`], except legacy `.ai` type with binding + /// punctuation on, which opens with [`Burasagari::Forced`]. pub burasagari: Burasagari, /// Kinsoku (Paragraph panel › Kinsoku Set): which Japanese characters may not start or end a /// line. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8835d769f..b251b15a6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -84,8 +84,8 @@ pub use reshape::{ }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ - EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, extrude_options, has_solid, inflate_options, is_three_d, solid_art, validate_solid, - validate_three_d, view_options, + EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, + three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; diff --git a/crates/effects/src/live.rs b/crates/effects/src/live.rs index ee88f485c..c4b6be4b7 100644 --- a/crates/effects/src/live.rs +++ b/crates/effects/src/live.rs @@ -39,6 +39,8 @@ pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option { /// Run `f` with the hooks live evaluation needs; symbols and patterns come from `doc` (without a /// document, symbol instances and pattern tiles stay as they are). fn with_hooks(doc: Option<&Document>, f: impl FnOnce(Hooks) -> R) -> R { + // Envelopes map type from its glyphs here too, without an engine session. + vectorcraft_text::register_envelope_ink(); let outline = |n: &Node| outline_object(doc, n); let tiles = |name: &str, xf: Affine, region: Rect| Some(doc?.pattern(name)?.instances_in(xf, region)); f(Hooks { outline: Some(&outline), appearance: Some(&bake_appearance), pattern: Some(&tiles), symbols: doc }) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 7025f8f6f..3319d79af 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -25,6 +25,32 @@ pub fn is_three_d(id: &str) -> bool { id == crate::REVOLVE || SOLIDS.contains(&id) } +/// The 3D and Materials panel's object types and the effect that draws each: (type, effect id). +/// Plane keeps the art flat, which is what Rotate draws. +pub const THREE_D_TYPES: [(&str, &str); 4] = [("plane", ROTATE), ("extrude", EXTRUDE), ("revolve", crate::REVOLVE), ("inflate", INFLATE)]; + +/// The parameters of 3D effect `to` for an effect `from` with `params`, as switching the object +/// type keeps them: every setting `to` takes keeps its value from `params` (the defaults of `from` +/// under them), each rotation angle wrapped into -180° to 180° when `to` is not Revolve, and the +/// settings only `to` takes start at their defaults. Settings `to` does not take are dropped. +pub fn three_d_params_as(from: &str, params: &Value, to: &str) -> Value { + let current = crate::merged_params(from, params); + let mut out = crate::default_params(to).and_then(|v| v.as_object().cloned()).unwrap_or_default(); + for (key, value) in out.iter_mut() { + if let Some(v) = current.get(key) { + value.clone_from(v); + } + } + if to != crate::REVOLVE { + for key in ["rotationX", "rotationY", "rotationZ"] { + if let Some(a) = out.get(key).and_then(Value::as_f64).filter(|a| !(-180.0..=180.0).contains(a)) { + out.insert(key.into(), serde_json::json!((a + 180.0).rem_euclid(360.0) - 180.0)); + } + } + } + Value::Object(out) +} + fn solid(fx: &[Effect]) -> Option<(usize, &Effect)> { fx.iter().enumerate().find(|(_, e)| e.visible && SOLIDS.contains(&e.id.as_str())) } diff --git a/crates/effects/tests/envelope_ink.rs b/crates/effects/tests/envelope_ink.rs new file mode 100644 index 000000000..a95906cc1 --- /dev/null +++ b/crates/effects/tests/envelope_ink.rs @@ -0,0 +1,29 @@ +//! Live evaluation without an engine session makes envelopes measure type by its glyph outlines. +//! One test in its own process, so nothing else has registered the measure before the evaluation. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect}; + +/// The largest distance between the edges of `a` and `b`. +fn off(a: Rect, b: Rect) -> f64 { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())) +} + +#[test] +fn live_evaluation_measures_type_in_envelopes_by_its_glyphs() { + let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() }); + let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + let laid = n.geometric_bounds().unwrap(); + let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap(); + assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}"); + + let _ = vectorcraft_effects::expand_live(None, &n); + let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap(); + assert!(off(after, ink) < 1e-6, "after live evaluation, type is measured by its glyphs: {after:?} vs {ink:?}"); +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6789a2116..eddf966db 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -66,3 +66,5 @@ vectorcraft-pdf = { workspace = true, features = ["read-count"] } wat = { workspace = true } # Zstandard streams for the Illustrator editing data fuzz tests. ruzstd = { workspace = true } +# The JavaScript parser's lexer, compared with the script checker's on the same scripts. +vectorcraft-js = { workspace = true } diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index f02d6f6aa..a26c75da2 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -575,21 +575,26 @@ pub(crate) fn named_id(p: &Value) -> Result> { } } -/// Objects a command targets: explicit `ids` param or the selection. -pub(crate) fn targets(s: &Session, p: &Value) -> Result> { - // Objects given are used as given: a value that isn't an object id fails rather than the - // command acting on the selection instead (#785). - match p.get("ids").filter(|v| !v.is_null()) { - Some(Value::Array(a)) => return a.iter().map(object_id).collect(), - Some(v) => return Err(EngineError::Other(format!("`ids` must be an array of object ids, not {v}"))), - None => {} +/// The object `p` gives as `key`, for a command that acts on the selection (or makes something new) +/// without it: `None` when `p` leaves it out or gives null. Any other value that isn't an object id +/// (`"abc"`, `"5"`, `true`, `{}`, `-1`, `2.5`) is an error under `cmd` that names `key`, never the +/// selection. The id is not looked up in the document. +pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(r)) if key == "id" && r.starts_with('$') => { + Err(bad(cmd, format!("`id` is the step reference {r:?}, which only `run` and `command_batch` resolve"))) + } + Some(v) => v.as_u64().map(|id| Some(NodeId(id))).ok_or_else(|| bad(cmd, format!("`{key}` must be a whole number"))), } - match p.get("id").filter(|v| !v.is_null()) { - Some(v @ Value::Number(_)) => Ok(vec![object_id(v)?]), - // A step reference a batch couldn't resolve (other strings are other ids, such as an - // effect's). - Some(Value::String(r)) if r.starts_with('$') => Err(unresolved_reference(r)), - _ => Ok(s.doc()?.selection.objects.clone()), +} + +/// Objects a command targets: its `ids`, else its `id` as `ids` of one ([`named_ids`]), else the +/// selection. An `ids` or `id` that isn't an object id is an error, never the selection. +pub(crate) fn targets(s: &Session, p: &Value) -> Result> { + match named_ids(p)? { + Some(ids) => Ok(ids), + None => Ok(s.doc()?.selection.objects.clone()), } } @@ -722,6 +727,10 @@ pub use pathops::shape_count; /// The gaps View › Show Live Paint Gaps shows on a Live Paint group. pub use buildcmds::live_paint_gaps; +/// Whether Reset Transformation changes a symbol instance (the Symbols panel menu and the +/// Control bar enable it when it changes one). +pub use brushsym::reset_transformation_changes; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/brushsym.rs b/crates/engine/src/cmd/brushsym.rs index 3e097f8fc..8cf3a2195 100644 --- a/crates/engine/src/cmd/brushsym.rs +++ b/crates/engine/src/cmd/brushsym.rs @@ -24,6 +24,9 @@ use super::{ }; use crate::EngineError; +/// Whether Reset Transformation changes a symbol instance. +pub use symbols::reset_transformation_changes; + mod brushes; mod dynamic; mod symbols; @@ -40,8 +43,8 @@ pub(crate) use symbols::{ stored_natural_size, }; use symbols::{ - symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_list, symbol_new, symbol_options, symbol_place, symbol_replace, - symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, + symbol_adjust, symbol_break_link, symbol_delete, symbol_duplicate, symbol_instance_reset_transform, symbol_list, symbol_new, symbol_options, + symbol_place, symbol_replace, symbol_select_instances, symbol_set_current, symbol_spray, symbol_unused, symbol_update, }; const SIZES_KEY: &str = vectorcraft_doc::SYMBOL_SIZES; @@ -140,7 +143,7 @@ pub fn specs() -> Vec { "Brush Options…", [], None, - "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", + "{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure|tilt|bearing|rotation for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest; tilt from value − variation with the pen lying flat to value + variation with it upright; bearing and rotation add variation × the pen's bearing (which way it leans, counter-clockwise from leaning right) or its barrel's counter-clockwise rotation over 180°; the angle turns on past ±180°). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure|tilt|bearing|rotation for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest; tilt: a with the pen lying flat to b upright; bearing and rotation: a at −180° to b at 180°), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art (drawn as the corner that joins a side tile on its right to one below it) or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art); side_tile, outer_corner_tile, inner_corner_tile, start_tile, end_tile: a document pattern name, original, or none (side refuses none); corner tiles also take autoCentered|autoBetween|autoSliced|autoOverlap. Each tile also accepts {pattern: name} to select a swatch whose name is a reserved choice. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}", has_doc, brush_options ), @@ -272,6 +275,15 @@ pub fn specs() -> Vec { has_selection, symbol_instance_reset ), + cmd!( + "symbol.instance.resetTransform", + "Reset Transformation", + [], + None, + "{ids?|id?} give the selected symbol instances (in groups too) their symbol's own scale, without rotation, shear or reflection, keeping the center of each one's bounding box in place, as one undo step; other objects, instances in perspective and instances already at their symbol's scale are left as they are, and no undo step is added when nothing changes; an id naming no object, or no symbol instance among the objects, is an error → {ids: the instances changed}", + has_selection, + symbol_instance_reset_transform + ), cmd!( "symbol.setCurrent", "Current Symbol", diff --git a/crates/engine/src/cmd/brushsym/symbols.rs b/crates/engine/src/cmd/brushsym/symbols.rs index 406511ff1..69bfcb66e 100644 --- a/crates/engine/src/cmd/brushsym/symbols.rs +++ b/crates/engine/src/cmd/brushsym/symbols.rs @@ -696,6 +696,62 @@ pub(super) fn symbol_replace(s: &mut Session, p: &Value) -> Result { Ok(json!({ "ids": ids_json(&ids) })) } +/// How far a transform coefficient may be from Reset Transformation's for the instance to count as +/// already reset. +const RESET_TOLERANCE: f64 = 1e-9; + +/// The transform Reset Transformation gives instance `n` of the document of `boxes`: its symbol's +/// own scale, without rotation, shear or reflection, with the center of its art's bounding box +/// where it is. `None` when [`reset_transformation_changes`] is false for `n`. +fn reset_transformation(boxes: &vectorcraft_doc::SymbolBoxes, n: &Node) -> Option { + let d = boxes.document(); + if !reset_transformation_changes(d, n) { + return None; + } + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let scale = d.symbol_natural_xf(symbol); + // The art's box in symbol space (the ±HALF square when the art cannot be measured): its + // center keeps its place on the page. + let art = boxes.art_bounds(symbol).unwrap_or_else(|| vectorcraft_geom::Rect::new(-HALF, -HALF, HALF, HALF)); + let center = xf.transform_rect_bbox(art).center(); + Some(Affine::translate(center - scale * art.center()) * scale) +} + +/// Does Reset Transformation change `n`? True for an instance of a symbol `d` holds, not in +/// perspective, whose scale, rotation, shear or reflection differs from the symbol's own scale +/// (its stored natural size, or the art unscaled when that size is not positive and finite). +/// It does not measure the art. +pub fn reset_transformation_changes(d: &Document, n: &Node) -> bool { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return false }; + if n.extras().perspective.is_some() || !d.symbols.iter().any(|s| s.name == *symbol) { + return false; + } + let natural = d.symbol_natural_xf(symbol).as_coeffs(); + xf.as_coeffs()[..4].iter().zip(&natural[..4]).any(|(a, b)| (a - b).abs() > RESET_TOLERANCE) +} + +pub(super) fn symbol_instance_reset_transform(s: &mut Session, p: &Value) -> Result { + let ids = selected_instances(s, p)?; + if ids.is_empty() { + return Err(EngineError::Other("no symbol instances selected".into())); + } + let d = &s.doc()?.doc; + let boxes = d.symbol_boxes(); + let changes: Vec<(NodeId, Affine)> = ids.iter().filter_map(|id| Some((*id, reset_transformation(&boxes, d.node(*id)?)?))).collect(); + if !changes.is_empty() { + s.edit("Reset Transformation", |d, _| { + for (id, new) in &changes { + if let Some(NodeKind::SymbolInstance { xf, .. }) = d.node_mut(*id).map(|n| &mut n.kind) { + *xf = *new; + } + } + Ok(()) + })?; + } + let changed: Vec = changes.iter().map(|(id, _)| *id).collect(); + Ok(json!({ "ids": ids_json(&changed) })) +} + pub(super) fn symbol_set_current(s: &mut Session, p: &Value) -> Result { let name = resolve_symbol(&s.doc()?.doc, p, "symbol.setCurrent")?; set_unknown(s, CURRENT_SYMBOL, Some(json!(name)))?; diff --git a/crates/engine/src/cmd/clipboard/resources.rs b/crates/engine/src/cmd/clipboard/resources.rs index 98824d142..4488b4746 100644 --- a/crates/engine/src/cmd/clipboard/resources.rs +++ b/crates/engine/src/cmd/clipboard/resources.rs @@ -241,9 +241,29 @@ pub(super) struct Used { impl Used { fn of<'a>(defs: &Defs<'a>, nodes: impl IntoIterator) -> Self { let mut used = Used::default(); - let mut todo: Vec> = nodes.into_iter().map(Source::Node).collect(); + used.follow(defs, nodes.into_iter().map(Source::Node).collect()); + used + } + + /// Every character style of `defs` (with `chars`) and every paragraph style (with `paras`), and + /// the resources they use, styles of the other kind included. + fn of_text_styles(defs: &Defs<'_>, chars: bool, paras: bool) -> Self { + let mut used = Used::default(); + let mut todo = vec![]; + for s in defs.char_styles.iter().filter(|_| chars) { + used.note(defs, Ref::Name(Res::CharStyle, &s.name), &mut todo); + } + for s in defs.para_styles.iter().filter(|_| paras) { + used.note(defs, Ref::Name(Res::ParaStyle, &s.name), &mut todo); + } + used.follow(defs, todo); + used + } + + /// Note the references of `todo`'s sources and of the definitions they lead to. + fn follow<'a>(&mut self, defs: &Defs<'a>, mut todo: Vec>) { while let Some(src) = todo.pop() { - let mut found = |r: Ref<'_>| used.note(defs, r, &mut todo); + let mut found = |r: Ref<'_>| self.note(defs, r, &mut todo); match src { Source::Node(n) => node_refs(n, &mut found), Source::Appearance(a) => appearance_refs(a, &mut found), @@ -251,7 +271,6 @@ impl Used { Source::Paint(p) => paint_refs(p, &mut found), } } - used } /// Note `r`; a definition seen for the first time joins `todo`. @@ -505,7 +524,7 @@ impl SwatchChoices { } /// How pasted art is relinked to the document's resources. -#[derive(Debug, Default)] +#[derive(Clone, Debug, Default)] struct Relink { renames: BTreeMap>, /// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped. @@ -665,6 +684,227 @@ impl SymbolOptions { } } +/// A character or paragraph style, symbol (with the Symbol Options copied with it), pattern or +/// brush (placed with the others only when they are placed in use order) to place +/// ([`Clipboard::import_used`]). +#[derive(Clone, Copy)] +enum Def<'c> { + Style(Res, &'c TextStyleDef), + Symbol(&'c Symbol, Option<&'c SymbolOptions>), + Pattern(&'c PatternDef), + Brush(&'c Brush), +} + +impl<'c> Def<'c> { + fn key(&self) -> (Res, &'c str) { + match self { + Def::Style(r, s) => (*r, &s.name), + Def::Symbol(s, _) => (Res::Symbol, &s.name), + Def::Pattern(p) => (Res::Pattern, &p.name), + Def::Brush(b) => (Res::Brush, &b.name), + } + } + + /// Call `f` with each resource it refers to by name. + fn each_use(&self, f: &mut dyn FnMut(Res, &str)) { + let mut g = |r: Ref<'_>| { + if let Ref::Name(res, n) = r { + f(res, n); + } + }; + match self { + Def::Style(_, s) => attrs_refs(&s.attrs, &mut g), + Def::Symbol(s, _) => node_refs(&s.art, &mut g), + Def::Pattern(p) => p.art.iter().for_each(|a| node_refs(a, &mut g)), + Def::Brush(b) => b.art().into_iter().for_each(|a| node_refs(a, &mut g)), + } + } + + /// Is it the definition `d` has under `name`, once relinked by `r`? (`brushes`: the brush + /// library of `d`.) + fn same_in(&self, d: &Document, brushes: &Brushes<'_>, name: &str, r: &Relink) -> bool { + match self { + Def::Style(res, s) => { + let theirs = if *res == Res::CharStyle { &d.char_styles } else { &d.para_styles }; + let mut attrs = s.attrs.clone(); + r.attrs(&mut attrs); + theirs.iter().any(|x| x.name == name && x.attrs == attrs) + } + Def::Symbol(s, options) => d.symbols.iter().any(|x| { + x.name == name + && options.is_none_or(|o| SymbolOptions::of(d, name) == *o) + && same_art(r, std::slice::from_ref(&x.art), std::slice::from_ref(&s.art)) + }), + Def::Pattern(p) => { + let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; + d.pattern(name).is_some_and(|x| bare(x) == bare(p) && same_art(r, &x.art, &p.art)) + } + Def::Brush(b) => brushes.get().iter().any(|x| x.name == name && same_brush(r, x, b)), + } + } + + /// The names of its kind in `d` (`brushes`: its brush library) that are its name or a numbered + /// copy of it. + fn names_in(&self, d: &Document, brushes: &Brushes<'_>) -> Vec { + let (res, name) = self.key(); + let names: Vec = match res { + Res::CharStyle => d.char_styles.iter().map(|s| s.name.clone()).collect(), + Res::ParaStyle => d.para_styles.iter().map(|s| s.name.clone()).collect(), + Res::Symbol => d.symbols.iter().map(|s| s.name.clone()).collect(), + Res::Brush => brushes.get().iter().map(|b| b.name.clone()).collect(), + _ => d.patterns.iter().map(|p| p.name.clone()).collect(), + }; + names.into_iter().filter(|n| copy_of(name, n)).collect() + } +} + +/// `defs` in rounds: each round takes those that use none of the others left, in their order. +/// → (those, the rest, which use each other in a loop or use one that does, in their order) +fn use_order(defs: Vec>) -> (Vec>, Vec>) { + let uses = uses_of(&defs); + // The round of each definition; `None` for one in a loop or one that uses such a definition. + let mut round: Vec> = vec![None; defs.len()]; + // A group outside a loop holds one definition, and comes after the groups it uses. + for (group, looped) in components(&uses) { + for v in group.into_iter().filter(|_| !looped) { + let r = uses[v].iter().try_fold(0, |r, &w| round.get(w).copied().flatten().map(|k| r.max(k + 1))); + round[v] = r; + } + } + let mut ready: Vec<(usize, usize)> = round.iter().enumerate().filter_map(|(i, r)| r.map(|r| (r, i))).collect(); + ready.sort_unstable(); + let order = ready.into_iter().filter_map(|(_, i)| defs.get(i).copied()).collect(); + let rest = defs.iter().zip(&round).filter(|(_, r)| r.is_none()).map(|(def, _)| *def).collect(); + (order, rest) +} + +/// The positions in `defs` of the definitions each one uses, itself included. +fn uses_of(defs: &[Def<'_>]) -> Vec> { + let mut at: BTreeMap<(Res, &str), Vec> = BTreeMap::new(); + for (i, def) in defs.iter().enumerate() { + at.entry(def.key()).or_default().push(i); + } + defs.iter() + .map(|def| { + let mut to = vec![]; + def.each_use(&mut |res, n| to.extend(at.get(&(res, n)).into_iter().flatten())); + to.sort_unstable(); + to.dedup(); + to + }) + .collect() +} + +/// `rest` ([`use_order`]'s definitions that use each other in a loop or use one that does) split +/// into its loops and the definitions outside a loop, each group after the groups it uses: +/// (positions in `rest`, in their order; true for a loop, definitions that use each other or one +/// that uses itself). +fn loop_groups(rest: &[Def<'_>]) -> Vec<(Vec, bool)> { + components(&uses_of(rest)) +} + +/// The strongly connected components of the graph `uses` (each node's successors), each after +/// those it reaches: (nodes, in their order; true for a loop, two or more nodes or one that uses +/// itself). +fn components(uses: &[Vec]) -> Vec<(Vec, bool)> { + // Tarjan's algorithm, with an explicit stack of calls. + const NONE: usize = usize::MAX; + let (mut index, mut low, mut on) = (vec![NONE; uses.len()], vec![0; uses.len()], vec![false; uses.len()]); + let (mut stack, mut next, mut groups) = (vec![], 0, vec![]); + for root in 0..uses.len() { + if index[root] != NONE { + continue; + } + let mut calls: Vec<(usize, usize)> = vec![(root, 0)]; + (index[root], low[root], on[root]) = (next, next, true); + next += 1; + stack.push(root); + while let Some(&(v, k)) = calls.last() { + if let Some(&w) = uses[v].get(k) { + if let Some(top) = calls.last_mut() { + top.1 += 1; + } + if index[w] == NONE { + (index[w], low[w], on[w]) = (next, next, true); + next += 1; + stack.push(w); + calls.push((w, 0)); + } else if on[w] { + low[v] = low[v].min(index[w]); + } + continue; + } + calls.pop(); + if let Some(&(u, _)) = calls.last() { + low[u] = low[u].min(low[v]); + } + if low[v] == index[v] { + let mut group = vec![]; + while let Some(w) = stack.pop() { + on[w] = false; + group.push(w); + if w == v { + break; + } + } + group.sort_unstable(); + let looped = group.len() > 1 || uses[v].contains(&v); + groups.push((group, looped)); + } + } + } + groups +} + +/// The names in `d` that `looped` (a loop of definitions that use each other, [`loop_groups`]) +/// all have at once, each the same once relinked by `r` with those names: each one's name or a +/// numbered copy of it. The names are picked one definition at a time, in order, and each +/// definition is compared as soon as the names of those it uses are picked, so a pick that does +/// not fit is dropped with every combination that follows from it; at most 65,536 comparisons are +/// made. `None` when no combination fits, or none was found within that many comparisons. The brush +/// library of `d` is read once, the first time a brush is compared. +fn names_of_loop(d: &Document, looped: &[Def<'_>], r: &Relink) -> Option> { + let library = OnceCell::new(); + let brushes = Brushes::Lazy(d, &library); + let names: Vec> = looped.iter().map(|def| def.names_in(d, &brushes)).collect(); + if names.is_empty() || names.iter().any(Vec::is_empty) { + return None; + } + // The definitions compared once the name at each position is picked. + let mut compare_at: Vec> = vec![vec![]; looped.len()]; + for (i, uses) in uses_of(looped).iter().enumerate() { + compare_at[uses.iter().copied().fold(i, usize::max)].push(i); + } + let (mut pick, mut guess, mut budget, mut k) = (vec![0usize; looped.len()], r.clone(), 1usize << 16, 0); + loop { + let (res, own) = looped[k].key(); + guess.renames.entry(res).or_default().insert(own.to_string(), names[k][pick[k]].clone()); + let fits = compare_at[k].iter().all(|&i| { + budget = budget.saturating_sub(1); + looped[i].same_in(d, &brushes, &names[i][pick[i]], &guess) + }); + if fits && k + 1 == looped.len() { + return Some(names.iter().zip(&pick).map(|(l, &p)| l[p].clone()).collect()); + } + if budget == 0 { + return None; + } + if fits { + k += 1; + pick[k] = 0; + continue; + } + // The next name at this position, or back to the position before. + loop { + pick[k] += 1; + if pick[k] < names[k].len() { + break; + } + k = k.checked_sub(1)?; + } + } +} + /// What pasting did to the document's resources (part of the paste result). #[derive(Debug, Default)] pub(crate) struct Imported { @@ -704,6 +944,12 @@ impl Clipboard { pub(crate) fn gather(src: &Document, nodes: Vec) -> Self { let library = OnceCell::new(); let used = Used::of(&Defs::of(src, &library), &nodes); + Self::with_used(src, &used, &library, nodes) + } + + /// `nodes` and the resources of `src` that `used` names (`library`: the brush library of `src`, + /// read when a brush was used). + fn with_used(src: &Document, used: &Used, library: &OnceCell>, nodes: Vec) -> Self { let pick = |r: Res, list: &[TextStyleDef]| list.iter().filter(|s| used.has(r, &s.name)).cloned().collect::>(); // Read when a brush was used. let brushes = match (used.names.get(&Res::Brush), library.get()) { @@ -757,61 +1003,158 @@ impl Clipboard { /// Add the resources the objects use to `d` (inside the paste's undo step) and work out how /// pasted copies relink to them. pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported { - let used = self.used(); + self.import_used(d, &self.used(), choices, same_doc, false) + } + + /// [`Self::import_into`] for the resources `used` names. The character and paragraph styles + /// come before the symbols and patterns, whose art may set type in them; with `in_use_order`, + /// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses + /// ([`use_order`]), so that it is compared and relinked with their names here, and each loop + /// of definitions that use each other is matched on its own ([`loop_groups`]). + fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported { let mut out = Imported::default(); - self.import_images(d, &used, same_doc, &mut out); - self.import_swatches(d, &used, choices, same_doc, &mut out); + self.import_images(d, used, same_doc, &mut out); + self.import_swatches(d, used, choices, same_doc, &mut out); // Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art - // they may brush; the others once those are in. - let mut added_brushes: HashSet = self.import_brushes(d, &used, same_doc, false, &mut out).into_iter().collect(); - // Character and paragraph styles (their colours relinked first). - for (r, mine, theirs) in [(Res::CharStyle, &self.char_styles, &mut d.char_styles), (Res::ParaStyle, &self.para_styles, &mut d.para_styles)] { - for def in mine.iter().filter(|s| used.has(r, &s.name)) { - let mut s = def.clone(); + // they may brush; the others once those are in. With `in_use_order`, the brushes are + // placed in use order with the others. + let mut added_brushes: HashSet = + if in_use_order { HashSet::new() } else { self.import_brushes(d, used, same_doc, false, &mut out).into_iter().collect() }; + let mut defs: Vec> = vec![]; + for (r, list) in [(Res::CharStyle, &self.char_styles), (Res::ParaStyle, &self.para_styles)] { + defs.extend(list.iter().filter(|s| used.has(r, &s.name)).map(|s| Def::Style(r, s))); + } + defs.extend(self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)).map(|s| Def::Symbol(s, self.symbol_options.get(&s.name)))); + defs.extend(self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)).map(Def::Pattern)); + if in_use_order { + defs.extend(self.brushes.iter().filter(|b| used.has(Res::Brush, &b.name)).map(Def::Brush)); + } + // Groups to place in order; true for a loop of definitions that use each other. + let groups: Vec<(Vec>, bool)> = if in_use_order { + let (order, rest) = use_order(defs); + let loops = loop_groups(&rest).into_iter().map(|(at, looped)| (at.into_iter().filter_map(|i| rest.get(i).copied()).collect(), looped)); + std::iter::once((order, false)).chain(loops).collect() + } else { + vec![(defs, false)] + }; + // Styles (their colors relinked first), symbols and patterns (compared once relinked, + // symbols with their options too). The symbols and patterns added are relinked at the end, + // when every new name and graphic style id is known; with `in_use_order`, so are the + // styles added, which a loop of uses may place before a pattern they paint with. + let (mut added_symbols, mut added_patterns, mut added_styles) = (HashSet::new(), HashSet::new(), vec![]); + // Each definition, whether it is in a loop, and the loop it starts. + let steps = groups + .iter() + .flat_map(|(group, looped)| group.iter().enumerate().map(move |(i, def)| (*def, *looped, (*looped && i == 0).then_some(group)))); + // In a loop the document does not have all at once, every definition uses one that is + // added under a free name, so each one is added too. + let mut fresh = false; + // The names in the document of the loop being placed, when it has them all at once. + let mut matched: BTreeMap<(Res, String), String> = BTreeMap::new(); + // The document's brush library, read the first time a brush is placed in use order and + // stored again after each brush added; nothing else in the loop changes it. + let mut library: Option> = None; + for (def, looped, starts) in steps { + fresh &= looped; + if !looped { + matched.clear(); + } + // A loop of uses that the document has all at once is known under those names before + // its definitions are compared. + if let Some(group) = starts { + matched.clear(); + match names_of_loop(d, group, &out.relink) { + Some(names) => { + for (def, name) in group.iter().zip(names) { + let (res, own) = def.key(); + if own != name { + out.relink.renames.entry(res).or_default().insert(own.to_string(), name.clone()); + } + matched.insert((res, own.to_string()), name); + } + fresh = false; + } + None => fresh = true, + } + } + // A definition of such a loop is the document's under the name found for it. + let (res, own) = def.key(); + if let Some(name) = matched.get(&(res, own.to_string())) { + let p = if name == own { Placed::Existing } else { Placed::Reused(name.clone()) }; + out.placed(res, own, &p); + continue; + } + match def { + Def::Style(r, def) => { + let mut s = def.clone(); + out.relink.attrs(&mut s.attrs); + let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; + let p = place(theirs, &s, same_doc, |a, b| !fresh && a == b); + if in_use_order && let Some(name) = p.new_name(&def.name) { + added_styles.push((r, name, def)); + } + out.placed(r, &def.name, &p); + } + Def::Symbol(def, options) => { + let theirs: HashMap = match options { + Some(_) => d + .symbols + .iter() + .filter(|s| copy_of(&def.name, &s.name)) + .map(|s| (s.name.clone(), SymbolOptions::of(d, &s.name))) + .collect(), + None => HashMap::new(), + }; + let r = &out.relink; + let p = place(&mut d.symbols, def, same_doc, |a, b| { + !fresh + && options.is_none_or(|o| theirs.get(&a.name) == Some(o)) + && same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art)) + }); + if let Some(name) = p.new_name(&def.name) { + if let Some(o) = options { + o.give(d, &name); + } + added_symbols.insert(name); + } + out.placed(Res::Symbol, &def.name, &p); + } + Def::Pattern(def) => { + // Tile options alike, and the art once relinked. + let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; + let same = |n: &str| !fresh && d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); + let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| { + // Its swatch comes along under the same name. + let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); + add_pattern(d, PatternDef { name: name.clone(), ..def.clone() }); + Placed::added(&def.name, name) + }); + added_patterns.extend(p.new_name(&def.name)); + out.placed(Res::Pattern, &def.name, &p); + } + Def::Brush(b) => { + let lib = library.get_or_insert_with(|| vectorcraft_brush::library(d)); + let r = &out.relink; + let p = place(lib, b, same_doc, |ours, theirs| !fresh && same_brush(r, ours, theirs)); + if let Some(name) = p.new_name(&b.name) { + vectorcraft_brush::store(d, lib); + added_brushes.insert(name); + } + out.placed(Res::Brush, &b.name, &p); + } + } + } + if !in_use_order { + added_brushes.extend(self.import_brushes(d, used, same_doc, true, &mut out)); + } + self.import_graphic_styles(d, used, same_doc, &mut out); + for (r, name, def) in added_styles { + let theirs = if r == Res::CharStyle { &mut d.char_styles } else { &mut d.para_styles }; + if let Some(s) = theirs.iter_mut().find(|s| s.name == name) { + s.attrs = def.attrs.clone(); out.relink.attrs(&mut s.attrs); - let p = place(theirs, &s, same_doc, PartialEq::eq); - out.placed(r, &def.name, &p); } } - // Symbols and patterns, compared once relinked (symbols with their options too). Those - // added are relinked at the end, when every new name and graphic style id is known. - let mut added_symbols = HashSet::new(); - for def in self.symbols.iter().filter(|s| used.has(Res::Symbol, &s.name)) { - let options = self.symbol_options.get(&def.name); - let theirs: HashMap = match options { - Some(_) => { - d.symbols.iter().filter(|s| copy_of(&def.name, &s.name)).map(|s| (s.name.clone(), SymbolOptions::of(d, &s.name))).collect() - } - None => HashMap::new(), - }; - let r = &out.relink; - let p = place(&mut d.symbols, def, same_doc, |a, b| { - options.is_none_or(|o| theirs.get(&a.name) == Some(o)) && same_art(r, std::slice::from_ref(&a.art), std::slice::from_ref(&b.art)) - }); - if let Some(name) = p.new_name(&def.name) { - if let Some(o) = options { - o.give(d, &name); - } - added_symbols.insert(name); - } - out.placed(Res::Symbol, &def.name, &p); - } - let mut added_patterns = HashSet::new(); - for def in self.patterns.iter().filter(|p| used.has(Res::Pattern, &p.name)) { - // Tile options alike, and the art once relinked. - let bare = |p: &PatternDef| PatternDef { name: String::new(), art: vec![], ..p.clone() }; - let same = |n: &str| d.pattern(n).is_some_and(|x| bare(x) == bare(def) && same_art(&out.relink, &x.art, &def.art)); - let p = existing(&def.name, d.patterns.iter().map(|p| p.name.as_str()), same_doc, same).unwrap_or_else(|| { - // Its swatch comes along under the same name. - let name = unique_name(&def.name, |n| d.pattern(n).is_some() || d.swatch_name_taken(n)); - add_pattern(d, PatternDef { name: name.clone(), ..def.clone() }); - Placed::added(&def.name, name) - }); - added_patterns.extend(p.new_name(&def.name)); - out.placed(Res::Pattern, &def.name, &p); - } - added_brushes.extend(self.import_brushes(d, &used, same_doc, true, &mut out)); - self.import_graphic_styles(d, &used, same_doc, &mut out); for s in d.symbols.iter_mut().filter(|s| added_symbols.contains(&s.name)) { out.relink.node(Arc::make_mut(&mut s.art)); } @@ -925,6 +1268,43 @@ impl Clipboard { } } +/// Where a loaded text style is in the document ([`import_text_styles`]). +pub(crate) struct LoadedStyle { + /// A paragraph style (else a character style). + pub para: bool, + /// Its name in the document. + pub name: String, + /// It was added (else the document's own was the same). + pub added: bool, +} + +/// Add every character style of `src` (with `chars`) and every paragraph style (with `paras`) to +/// `d`, with the swatches, patterns and what their art uses (styles of the other kind included) +/// that the styles' fill and stroke lead to, as Paste adds those of pasted type +/// ([`Clipboard::import_into`]; a global or spot color whose name `d` gives another color is +/// `d`'s). Each style, symbol and pattern is placed after those it uses, so that a style follows +/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style +/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is +/// used; any other is added under a free name. → each style of `src` placed, in order, character +/// styles first. +pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec { + let library = OnceCell::new(); + let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras); + let clip = Clipboard::with_used(src, &used, &library, vec![]); + let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::>()); + let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true); + let mut placed: Vec = vec![]; + for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] { + for s in list { + let name = out.relink.renamed(r, &s.name); + let again = placed.iter().any(|p| p.para == para && p.name == name); + let added = !again && !before[usize::from(para)].contains(&name); + placed.push(LoadedStyle { para, name, added }); + } + } + placed +} + /// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups /// share one namespace). fn add_swatch(d: &mut Document, w: &Swatch, same_doc: bool) -> Placed { diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 9842cfbe9..7bdcfad58 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -19,7 +19,7 @@ use vectorcraft_render::proof; #[cfg(not(target_arch = "wasm32"))] use super::EngineError; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, color_value, has_doc, str_param, targets}; +use super::{CommandSpec, Result, Session, always, bad, cmd, color_value, has_doc, switch_or, targets, text_param}; /// `Document.unknown` key under which files before format v3 kept the assigned profiles (migrated /// to `Document::color_profiles` on load). @@ -100,7 +100,7 @@ pub fn specs() -> Vec { proof_setup ), cmd!(query "view.proofColors", "Proof Colors", ["View"], None, "{on?: bool (default: toggle)} → proof state", always, |s, p| { - toggle_view(s, p, |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors)) + toggle_view(s, p, "view.proofColors", |v, on| v.proof_colors = on.unwrap_or(!v.proof_colors)) }), cmd!( query "view.overprintPreview", @@ -109,7 +109,7 @@ pub fn specs() -> Vec { Some("Cmd+Alt+Shift+Y"), "{on?: bool (default: toggle)} → proof state", always, - |s, p| toggle_view(s, p, |v, on| v.overprint = on.unwrap_or(!v.overprint)) + |s, p| toggle_view(s, p, "view.overprintPreview", |v, on| v.overprint = on.unwrap_or(!v.overprint)) ), cmd!( query "view.separationsPreview", @@ -143,12 +143,21 @@ pub fn specs() -> Vec { } fn intent_param(p: &Value, cmd: &str) -> Result> { - match str_param(p, "intent") { + match text_param(p, "intent", cmd)? { None => Ok(None), Some(i) => Intent::parse(i).map(Some).ok_or_else(|| bad(cmd, format!("unknown intent `{i}`"))), } } +/// The true or false `p` gives for `key`, or `None` when `p` leaves it out or gives null. Any other +/// value is an error, as [`switch_or`] reads it. +fn on_param(p: &Value, key: &str, cmd: &str) -> Result> { + if p.get(key).is_none_or(Value::is_null) { + return Ok(None); + } + switch_or(p, key, false, cmd).map(Some) +} + fn settings_json(st: &ColorSettings) -> Value { json!({ "rgb": st.rgb, @@ -171,16 +180,16 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.colorSettings"; let mut st = cms::active_settings(); let before = st.clone(); - if let Some(v) = str_param(p, "rgb") { + if let Some(v) = text_param(p, "rgb", C)? { st.rgb = cms::canonical_name(v).to_string(); } - if let Some(v) = str_param(p, "cmyk") { + if let Some(v) = text_param(p, "cmyk", C)? { st.cmyk = cms::canonical_name(v).to_string(); } if let Some(i) = intent_param(p, C)? { st.intent = i; } - st.bpc = bool_or(p, "bpc", st.bpc); + st.bpc = switch_or(p, "bpc", st.bpc, C)?; if st != before { cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?; touch_all(s); @@ -191,7 +200,7 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { #[cfg(not(target_arch = "wasm32"))] fn load_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "color.loadProfile"; - let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?; + let path = text_param(p, "path", C)?.ok_or_else(|| bad(C, "missing `path`"))?; crate::file_access::check_read(path).map_err(EngineError::Other)?; let bytes = crate::store::read_capped(std::path::Path::new(path), crate::store::MAX_PROFILE_FILE).map_err(|e| bad(C, format!("{path}: {e}")))?; let info = cms::register_icc(&bytes, None).map_err(|e| bad(C, e.to_string()))?; @@ -209,7 +218,7 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { /// Forget a profile `color.loadProfile` loaded, and the copy that is kept of it. fn remove_profile(s: &mut Session, p: &Value) -> Result { const C: &str = "color.removeProfile"; - let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing `name`"))?; let info = cms::profiles().into_iter().find(|i| i.name == name).ok_or_else(|| bad(C, format!("no profile named `{name}`")))?; if info.builtin { return Err(bad(C, format!("`{name}` is a built-in profile and stays"))); @@ -289,7 +298,7 @@ pub enum Grays { impl Grays { /// The `grays` param of `cmd`: `"profile"` (default) or `"black"`. pub(crate) fn param(cmd: &str, p: &Value) -> Result { - match str_param(p, "grays").map(str::to_ascii_lowercase).as_deref() { + match text_param(p, "grays", cmd)?.map(str::to_ascii_lowercase).as_deref() { None | Some("profile") => Ok(Grays::Profile), Some("black") => Ok(Grays::Black), Some(g) => Err(bad(cmd, format!("grays must be \"profile\" or \"black\", not `{g}`"))), @@ -302,18 +311,18 @@ impl Grays { /// colour through the colour settings. pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result { const C: &str = "file.documentColorMode"; - let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() { + let mode = match p.get("mode").and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() { Some("cmyk") => ColorMode::Cmyk, Some("rgb") => ColorMode::Rgb, _ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")), }; let intent = intent_param(p, C)?; let grays = Grays::param(C, p)?; + let convert = switch_or(p, "convert", true, C)?; // Choosing the mode the document already has (the checked menu item) is not an edit. if s.doc()?.doc.color_mode == mode { return Ok(json!({ "changed": 0 })); } - let convert = bool_or(p, "convert", true); let mut changed = 0usize; s.edit("Document Color Mode", |d, _| { changed = set_color_mode(d, mode, convert, intent, grays); @@ -654,7 +663,7 @@ fn convert_color(_: &mut Session, p: &Value) -> Result { let c = cms::active(); let intent = intent_param(p, C)?.unwrap_or(c.settings().intent); let lab = c.lab(&col); - let (model, vals) = match str_param(p, "to").unwrap_or("rgb") { + let (model, vals) = match text_param(p, "to", C)?.unwrap_or("rgb") { "rgb" => ("rgb", values(&c.convert(&col, Model::Rgb, intent))), "cmyk" => ("cmyk", values(&c.convert(&col, Model::Cmyk, intent))), "gray" | "grayscale" => ("gray", values(&c.convert(&col, Model::Gray, intent))), @@ -674,7 +683,11 @@ fn convert_color(_: &mut Session, p: &Value) -> Result { fn gamut_check(s: &mut Session, p: &Value) -> Result { let mut cols: Vec = vec![]; - if let Some(a) = p.get("colors").and_then(Value::as_array) { + let colors = p.get("colors").filter(|v| !v.is_null()); + if let Some(v) = colors.filter(|v| !v.is_array()) { + return Err(bad("color.gamutCheck", format!("`colors` must be an array of colors, not {v}"))); + } + if let Some(a) = colors.and_then(Value::as_array) { for v in a { cols.push(color_value(v).ok_or_else(|| bad("color.gamutCheck", format!("bad color {v}")))?); } @@ -728,7 +741,7 @@ fn view_json(v: &proof::ProofView) -> Value { fn proof_setup(s: &mut Session, p: &Value) -> Result { const C: &str = "view.proofSetup"; let mut v = proof::view(); - if let Some(t) = str_param(p, "target") { + if let Some(t) = text_param(p, "target", C)? { let t = ProofTarget::parse(t).ok_or_else(|| bad(C, format!("unknown proof target `{t}`")))?; if let ProofTarget::Cmyk(name) = &t && !cms::profile(name).is_some_and(|k| k.kind == cms::ProfileKind::Cmyk) @@ -740,8 +753,8 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { if let Some(i) = intent_param(p, C)? { v.setup.intent = i; } - v.setup.simulate_paper = bool_or(p, "simulatePaper", v.setup.simulate_paper); - if let Some(on) = p.get("proof").and_then(Value::as_bool) { + v.setup.simulate_paper = switch_or(p, "simulatePaper", v.setup.simulate_paper, C)?; + if let Some(on) = on_param(p, "proof", C)? { v.proof_colors = on; } proof::set_view(v.clone()); @@ -749,9 +762,10 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { Ok(view_json(&v)) } -fn toggle_view(s: &mut Session, p: &Value, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { +fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { + let on = on_param(p, "on", cmd)?; let mut v = proof::view(); - f(&mut v, p.get("on").and_then(Value::as_bool)); + f(&mut v, on); proof::set_view(v.clone()); touch_all(s); Ok(view_json(&v)) @@ -779,16 +793,23 @@ fn separations(s: &mut Session, p: &Value) -> Result { .map(|d| proof::plates(&d.doc).into_iter().map(|p| p.name).collect()) .unwrap_or_else(|| cms::PROCESS_PLATES.iter().map(|s| s.to_string()).collect()); let check = |n: &str| if all.iter().any(|a| a == n) { Ok(n.to_string()) } else { Err(bad(C, format!("unknown plate `{n}`"))) }; - if let Some(on) = p.get("on").and_then(Value::as_bool) { + if let Some(on) = on_param(p, "on", C)? { v.separations = if on { Some(v.separations.take().unwrap_or_else(|| all.clone())) } else { None }; } - if let Some(a) = p.get("plates").and_then(Value::as_array) { - v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + match p.get("plates") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) if a.iter().all(Value::is_string) => { + v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + } + Some(_) => { + let key = "plates"; + return Err(bad(C, format!("`{key}` must be an array of these names: {}", all.join(", ")))); + } } - if let Some(n) = str_param(p, "only") { + if let Some(n) = text_param(p, "only", C)? { v.separations = Some(vec![check(n)?]); } - if let Some(n) = str_param(p, "toggle") { + if let Some(n) = text_param(p, "toggle", C)? { let n = check(n)?; let vis = v.separations.get_or_insert_with(|| all.clone()); if let Some(i) = vis.iter().position(|x| *x == n) { @@ -804,8 +825,8 @@ fn separations(s: &mut Session, p: &Value) -> Result { fn set_spot(s: &mut Session, p: &Value) -> Result { const C: &str = "swatch.setSpot"; - let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string(); - let spot = bool_or(p, "spot", true); + let name = text_param(p, "name", C)?.ok_or_else(|| bad(C, "missing `name`"))?.to_string(); + let spot = switch_or(p, "spot", true, C)?; let sw = s.doc()?.doc.swatch(&name).ok_or_else(|| bad(C, format!("no swatch `{name}`")))?; if !matches!(sw.paint, Paint::Solid { .. }) { return Err(bad(C, "only solid-colour swatches can be spot colours")); diff --git a/crates/engine/src/cmd/draw2/anchors.rs b/crates/engine/src/cmd/draw2/anchors.rs index df55e5207..dbca59b00 100644 --- a/crates/engine/src/cmd/draw2/anchors.rs +++ b/crates/engine/src/cmd/draw2/anchors.rs @@ -14,7 +14,7 @@ pub(super) fn curvature(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "need at least one point")); } let sp = vectorcraft_tools::draw2::curvature_spline(&pts, switch_or(p, "closed", false, C)?); - if let Some(id) = id_param(p, "id") { + if let Some(id) = crate::cmd::object_param(p, "id", C)? { s.edit("Curvature", |d, sel| { *path_mut(d, id)? = PathData::single(sp); sel.set([id]); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 529d9513c..dbe3d4509 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -9,9 +9,9 @@ use vectorcraft_geom::{Affine, Point, Vec2}; use super::clipboard::SwatchChoices; use super::{ - CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, cmd, f64_or, f64_req, - finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, index, ok, - point_param, str_param, switch_or, + CommandSpec, Result, Session, always, bad, bool_or, can_clear_history, can_redo, can_undo, checked_ids_param, clock_date, cmd, date_param, + f64_or, f64_req, finite_numbers, has_clipboard, has_doc, has_selection, has_selection_guides_or_artboard_tool, has_selection_or_guides, id_param, + index, ok, point_param, str_param, switch_or, }; use crate::{Clipboard, EngineError, inspect}; @@ -116,6 +116,15 @@ pub fn specs() -> Vec { can_clear_history, clear_history ), + cmd!( + "edit.newDocumentFromState", + "New Document from State", + [], + None, + "{index?: a row of the History panel (0: the oldest state kept; default: the current state), created?: Unix seconds|null (File Info's created date; default now, recorded in the journal so a replay matches)} make a new document holding the art as it was at that state, and make it the active document (the History panel's New Document from State button). The new document is named with the next Untitled-N, has nothing selected and no steps to undo or redo, is marked unsaved and links to the same files. It is out of symbol, opacity-mask and pattern editing, with the art drawn in a pattern's tile stored in the pattern as object.pattern.done stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step, as edit.undo keeps it; `index` names the row as it was listed before that. An index that names no state is an error and changes nothing → {index: the new document's, title}", + has_doc, + new_document_from_state + ), cmd!( "edit.undoGroup.begin", "Begin Undo Group", @@ -606,6 +615,53 @@ fn clear_history(s: &mut Session, _: &Value) -> Result { Ok(json!({ "cleared": cleared })) } +/// History panel › New Document from State: a new document with the art of a state, and without +/// the editing state that state may hold (opacity-mask or symbol editing, Puppet Warp pins, new art). +/// Its links point at the files the source finds, which may be in the source's folder away from +/// the links' paths. Later artboards in the two documents take their ids separately. A later save of the new document +/// over the file its art was read from asks first, as a save of the source does. +fn new_document_from_state(s: &mut Session, p: &Value) -> Result { + const C: &str = "edit.newDocumentFromState"; + let created = date_param(p, "created", C)?; + let st = s.doc()?; + // The row as the panel lists it now: keeping an edit in progress below changes the rows. + let chosen = match p.get("index") { + None | Some(Value::Null) => None, + Some(v) => { + let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row))); + Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone()) + } + }; + settle_interaction(s)?; + let st = s.doc()?; + let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc)); + let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone()); + if let Some(path) = st.path.as_deref() { + // Files are looked for within the asking caller's roots, else within those of the caller that opened the source. + let roots = crate::file_access::current().or_else(|| opened_by.clone()); + crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path)); + } + doc.drop_edit_modes(); + if doc.pattern_edit.is_some() { + super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?; + } + doc.puppet = None; + doc.new_art.set(Arc::default()); + let retired = vectorcraft_doc::RetiredArtboardId::default(); + retired.raise(doc.retired_artboard_id.get()); + doc.retired_artboard_id = retired; + doc.title = s.next_untitled(); + doc.metadata.created = clock_date(s, "created", created); + doc.metadata.modified = None; + let i = s.add_document(doc, None); + let st = s.doc_mut()?; + st.imported_from = imported_from; + st.import_losses = import_losses; + st.opened_by = st.opened_by.take().or(opened_by); + st.mark_unsaved(); + Ok(json!({ "index": i, "title": st.title() })) +} + /// `selection` for an undo (redo) step, sharing that of `like`, the step beside it, when the same. fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::HistoryEntry>) -> Arc { match like { diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index b67d5bb97..21f43e0ee 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -53,7 +53,7 @@ pub fn specs() -> Vec { "Effect Options", [], None, - "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", + "{index: int, params?: {…} (merged into the current parameters), warpStyle?: string (an existing Warp style suffix, e.g. arc or flag; changes only an applied Warp in place), threeDType?: plane|extrude|revolve|inflate (the 3D and Materials panel's object type: changes only an applied 3D effect in place into Rotate, Extrude & Bevel, Revolve or Inflate; the settings the new effect also takes keep their values, rotation angles wrapped into -180..180 outside Revolve, and the others start at its defaults; params then merge into the new effect's; the type the effect already has, with nothing else given, changes nothing and records no undo step), visible?: bool, item?: appearance item index|null (as effect.remove), ids?|id?: [..], target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} → {ids}", has_doc, set_params ), @@ -222,6 +222,16 @@ pub(crate) const ONE_3D: &str = "An object takes one 3D effect. Edit the existin pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.apply"; let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?; + // An effect given as `id` names no objects: the targets are read with that `id` as null. + let effect_as_id; + let p = if str_param(p, "effect").is_none() { + let mut q = p.clone(); + q["id"] = Value::Null; + effect_as_id = q; + &effect_as_id + } else { + p + }; let mut params = p.get("params").cloned().unwrap_or(Value::Null); if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); @@ -422,10 +432,28 @@ pub(crate) fn set_params_effects(s: &Session, p: &Value) -> Vec { return vec![]; }; let Ok(targets) = effect_targets(s, p, item, index, C) else { return vec![] }; - let effect = |&(id, at, index): &(NodeId, Option, usize)| Some(st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id.clone()); + // A 3D effect whose type `threeDType` changes takes the new effect's parameters. + let kind = three_d_type(p, C).ok().flatten(); + let effect = |&(id, at, index): &(NodeId, Option, usize)| { + let id = &st.doc.node(id)?.appearance.effects_at(at)?.get(index)?.id; + Some(kind.filter(|_| effects::is_three_d(id)).map_or_else(|| id.clone(), str::to_string)) + }; targets.iter().filter_map(effect).collect() } +/// The effect `threeDType` in `p` turns a 3D effect into ([`effects::THREE_D_TYPES`]), when given. +fn three_d_type(p: &Value, cmd: &str) -> Result> { + let key = "threeDType"; + let Some(v) = p.get(key) else { return Ok(None) }; + match v.as_str().and_then(|t| effects::THREE_D_TYPES.iter().find(|(name, _)| *name == t)) { + Some((_, id)) => Ok(Some(id)), + None => { + let names: Vec<&str> = effects::THREE_D_TYPES.iter().map(|(name, _)| *name).collect(); + Err(bad(cmd, format!("`{key}` must be one of {}", names.join(", ")))) + } + } +} + fn lighting_preset(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.lightingPreset"; let patch = str_param(p, "preset") @@ -450,8 +478,9 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result { } /// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and -/// sets its Warp style (`warpStyle`) or visibility (`visible`), as one undo step. They are checked -/// as `effect.apply` checks them, and a visible 3D effect must still fit. → `{ids}`. +/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one +/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still +/// fit. → `{ids}`. fn set_params(s: &mut Session, p: &Value) -> Result { const C: &str = "effect.setParams"; let index = index_param(p, "index", C)?; @@ -459,6 +488,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if !params.is_null() && !params.is_object() { return Err(bad(C, "`params` must be an object")); } + let kind = three_d_type(p, C)?; let warp_id = p .get("warpStyle") .map(|v| -> Result { @@ -476,21 +506,41 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if edited.iter().any(|e| texture_load_effect(e)) { load_texture(&mut params, C)?; } - for (id, target, index) in effect_targets(s, p, item, index, C)? { + let targets = effect_targets(s, p, item, index, C)?; + // The type each 3D effect already has, with nothing else to change, is no edit (and is not + // checked again). + let nothing_else = params.as_object().is_none_or(serde_json::Map::is_empty) && visible.is_none() && warp_id.is_none(); + if let Some(to) = kind + && nothing_else + { + let d = &s.doc()?.doc; + // The effects edited: a target without an effect at `index` is left as it is. + let ids: Vec<&str> = + targets.iter().filter_map(|&(id, at, index)| d.node(id)?.appearance.effects_at(at)?.get(index).map(|e| e.id.as_str())).collect(); + if !ids.is_empty() && ids.iter().all(|id| *id == to) { + return Ok(json!({ "ids": [] })); + } + } + for &(id, target, index) in &targets { let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?; let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index)); if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) { return Err(bad(C, "warpStyle can only edit an applied Warp effect")); } + if kind.is_some() && effect.is_some_and(|e| !effects::is_three_d(&e.id)) { + return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0))); + } if let Some(e) = effect && effects::is_three_d(&e.id) && visible != Some(false) { - let mut prospective = effects::merged_params(&e.id, &e.params); - if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, ¶ms)) { + let to = kind.unwrap_or(&e.id); + let mut prospective = + if to == e.id { effects::merged_params(&e.id, &e.params) } else { effects::three_d_params_as(&e.id, &e.params, to) }; + if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, ¶ms)) { cur.extend(new); } - effects::validate_three_d(n, &e.id, &prospective).map_err(|e| bad(C, e))?; + effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?; } } let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| { @@ -498,6 +548,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result { if let Some(id) = &warp_id { e.id.clone_from(id); } + if let Some(to) = kind + && e.id != to + { + e.params = effects::three_d_params_as(&e.id, &e.params, to); + e.id = to.to_string(); + } if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) { if !cur.is_object() { *cur = effects::merged_params(&e.id, &Value::Null); @@ -859,5 +915,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod lighting_presets_tests; +#[cfg(test)] +mod three_d_type_tests; + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs b/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs new file mode 100644 index 000000000..5fef22909 --- /dev/null +++ b/crates/engine/src/cmd/effectcmd/three_d_type_tests.rs @@ -0,0 +1,144 @@ +#![allow(clippy::unwrap_used, clippy::panic)] +use serde_json::{Value, json}; +use vectorcraft_doc::NodeId; + +use crate::Session; + +fn setup(effect: &str, params: Value) -> (Session, NodeId) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width":400,"height":400})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#338ad6"})).unwrap(); + s.execute("effect.apply", &json!({"effect":effect,"item":null,"params":params})).unwrap(); + (s, id) +} + +fn effect(s: &Session, id: NodeId) -> (String, Value) { + let e = &s.doc().unwrap().doc.node(id).unwrap().appearance.effects[0]; + (e.id.clone(), e.params.clone()) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn three_d_type_turns_the_effect_in_place_keeping_shared_settings_in_one_undo_step() { + let types = [("plane", "threeD.rotate"), ("extrude", "threeD.extrude"), ("revolve", "threeD.revolve"), ("inflate", "threeD.inflate")]; + for (from_type, from) in types { + for (to_type, to) in types { + if from == to { + continue; + } + let (mut s, id) = setup(from, json!({"rotationX": 12.0, "rotationY": -30.0, "rotationZ": 5.0, "ambient": 40.0, "lightIntensity": 55.0})); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let p = json!({"index": 0, "item": null, "threeDType": to_type}); + s.check_params("effect.setParams", &p).unwrap(); + s.execute("effect.setParams", &p).unwrap(); + let (now, params) = effect(&s, id); + assert_eq!(now, to, "{from_type} → {to_type}"); + assert_eq!(params["rotationX"], 12.0, "{from_type} → {to_type}"); + assert_eq!(params["rotationY"], -30.0); + assert_eq!(params["ambient"], 40.0); + assert_eq!(params["lightIntensity"], 55.0); + // Settings only the new effect takes start at its defaults; others are dropped. + let defaults = vectorcraft_render::effects::default_params(to).unwrap(); + for key in params.as_object().unwrap().keys() { + assert!(defaults.get(key).is_some(), "{from_type} → {to_type} kept `{key}`"); + } + if to == "threeD.extrude" { + assert_eq!(params["bevel"], defaults["bevel"]); + } + assert_eq!(undo_len(&s), history + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc, before); + } + } +} + +#[test] +fn three_d_type_carries_depth_between_extrude_and_inflate_and_merges_params_after() { + let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0, "twist": 30.0, "bevel": "classic"})); + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "inflate", "params": {"volume": 70.0}})).unwrap(); + let (now, params) = effect(&s, id); + assert_eq!(now, "threeD.inflate"); + assert_eq!(params["depth"], 80.0); + assert_eq!(params["twist"], 30.0); + assert_eq!(params["volume"], 70.0); + assert!(params.get("bevel").is_none()); +} + +#[test] +fn revolve_angles_outside_half_a_turn_wrap_when_the_type_leaves_revolve() { + let (mut s, id) = setup("threeD.revolve", json!({"rotationX": 270.0, "rotationY": -200.0})); + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "plane"})).unwrap(); + let (_, params) = effect(&s, id); + assert_eq!(params["rotationX"], -90.0); + assert_eq!(params["rotationY"], 160.0); +} + +#[test] +fn the_current_type_alone_changes_nothing_and_records_no_undo_step() { + let (mut s, id) = setup("threeD.extrude", json!({"depth": 80.0})); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + let r = s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude"})).unwrap(); + assert_eq!(r["ids"], json!([])); + assert_eq!(undo_len(&s), history); + assert_eq!(s.doc().unwrap().doc, before); + // With other changes the edit runs as usual. + s.execute("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 90.0}})).unwrap(); + assert_eq!(effect(&s, id).1["depth"], 90.0); + assert_eq!(undo_len(&s), history + 1); +} + +#[test] +fn the_current_type_with_an_object_without_effects_selected_too_records_no_undo_step() { + let (mut s, a) = setup("threeD.extrude", json!({})); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let history = undo_len(&s); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap(); + assert_eq!(undo_len(&s), history); + assert_eq!(s.doc().unwrap().doc, before); +} + +#[test] +fn three_d_type_rejects_unknown_types_and_effects_that_are_not_3d() { + let (mut s, _) = setup("threeD.extrude", json!({})); + let before = s.doc().unwrap().doc.clone(); + for t in [json!("cube"), json!(""), json!(null), json!(2), json!("Plane"), json!("plane/../x")] { + let p = json!({"index": 0, "threeDType": t}); + let err = s.execute("effect.setParams", &p).unwrap_err().to_string(); + assert!(err.contains("must be one of plane, extrude, revolve, inflate"), "{t}: {err}"); + } + assert_eq!(s.doc().unwrap().doc, before); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + let err = s.execute("effect.setParams", &json!({"index": 1, "threeDType": "plane"})).unwrap_err().to_string(); + assert!(err.contains("needs a 3D effect at index 1"), "{err}"); + let err = s.execute("effect.setParams", &json!({"index": 5, "threeDType": "plane"})).unwrap_err().to_string(); + assert!(err.contains("no effect at index 5"), "{err}"); +} + +#[test] +fn three_d_type_turns_every_selected_object_in_one_undo_step() { + let (mut s, a) = setup("threeD.rotate", json!({})); + let b = NodeId(s.execute("path.create", &json!({"d":"M250 100L350 100L350 200L250 200Z"})).unwrap()["id"].as_u64().unwrap()); + s.execute("paint.setFill", &json!({"color":"#d63333"})).unwrap(); + s.execute("effect.apply", &json!({"effect":"threeD.rotate","item":null})).unwrap(); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let history = undo_len(&s); + s.execute("effect.setParams", &json!({"index": 0, "item": null, "aligned": true, "threeDType": "extrude"})).unwrap(); + assert_eq!(effect(&s, a).0, "threeD.extrude"); + assert_eq!(effect(&s, b).0, "threeD.extrude"); + assert_eq!(undo_len(&s), history + 1); +} + +#[test] +fn params_for_the_new_type_pass_the_parameter_check() { + let (s, _) = setup("threeD.rotate", json!({})); + // `depth` is an Extrude & Bevel setting, checked against the effect the type makes. + s.check_params("effect.setParams", &json!({"index": 0, "threeDType": "extrude", "params": {"depth": 20.0}})).unwrap(); +} diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 770c11644..74cfced2b 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -109,7 +109,7 @@ pub const AI_OPTIONS: &[FormatOption] = &[ name: "editingData", ty: "boolean", default: "true", - description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, and linked images as placed files with a preview, with type, other patterns, symbols, other clipping objects and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", + description: "also carry an editing copy of the artwork in the form other apps that open .ai files read: layers and sublayers, groups, compound paths, paths, fills and strokes, colors, linear and radial gradients, patterns on a grid of tiles, swatches, artboards and which one is active, the transparency of objects and layers, objects' opacity masks, clipping groups and layers clipped by a path, embedded images with their pixels, linked images as placed files with a preview, point type, area type and type on a path as text, and symbols and their instances, with other type, other patterns, other clipping objects, other symbol instances and live objects as plain filled and stroked paths, freeform gradients, gradients along or across a stroke and gradients of more than 256 stops as their average color, gradients that dither or mix perceptually as classic ones, and gradient stops linked to global process colors as their colors (written only with every artboard, as Preserve Editing is)", }, FormatOption { name: "aiVersion", diff --git a/crates/engine/src/cmd/fileio/pdfimport.rs b/crates/engine/src/cmd/fileio/pdfimport.rs index 5d2663910..9550cf90f 100644 --- a/crates/engine/src/cmd/fileio/pdfimport.rs +++ b/crates/engine/src/cmd/fileio/pdfimport.rs @@ -7,7 +7,7 @@ use vectorcraft_doc::{ColorMode, Document}; use vectorcraft_pdf::{Choice, CropTo, ImportOptions, PdfError, TextAs}; use super::super::colormgmt::Grays; -use super::super::{Result, Session, bad, f64_or, parse_range, str_param}; +use super::super::{Result, Session, bad, check_numbers, number_or, parse_range, text_param}; use super::{err, source}; /// The `document.open` options besides the file. The PDF ones (pages, box, password, text, @@ -75,16 +75,17 @@ impl LoadOptions { Some(Value::Number(n)) => Some(n.to_string()), Some(v) => return Err(bad(cmd, format!("pages must be a range such as \"2-3\" or a page number, not {v}"))), }; - let crop = choice(cmd, "cropTo", str_param(p, "cropTo").or_else(|| str_param(p, "crop")))?; - let password = str_param(p, "password").filter(|s| !s.is_empty()).map(str::to_string); - let color_mode = match str_param(p, "colorMode").map(str::to_ascii_lowercase).as_deref() { + let (crop_to, crop_alias) = (text_param(p, "cropTo", cmd)?, text_param(p, "crop", cmd)?); + let crop = choice(cmd, "cropTo", crop_to.or(crop_alias))?; + let password = text_param(p, "password", cmd)?.filter(|s| !s.is_empty()).map(str::to_string); + let color_mode = match text_param(p, "colorMode", cmd)?.map(str::to_ascii_lowercase).as_deref() { None => None, Some("rgb") => Some(ColorMode::Rgb), Some("cmyk") => Some(ColorMode::Cmyk), Some(m) => return Err(bad(cmd, format!("colorMode must be rgb or cmyk, not `{m}`"))), }; let grays = Grays::param(cmd, p)?; - let text_as = choice(cmd, "textAs", str_param(p, "textAs"))?; + let text_as = choice(cmd, "textAs", text_param(p, "textAs", cmd)?)?; let layers = match p.get("layers").filter(|v| !v.is_null()) { None => true, Some(v) => v.as_bool().ok_or_else(|| bad(cmd, "layers must be true or false"))?, @@ -172,6 +173,7 @@ const INFO: &str = "document.pdfInfo"; pub(super) fn pdf_info(_: &mut Session, p: &Value) -> Result { let bytes = source(p, INFO)?.bytes; let o = LoadOptions::from_params(INFO, p)?; + check_numbers(p, &["thumbnailSize"], INFO)?; // Programs whose header is only `%!` are PostScript too, as `clipboard.importEps` reads them. if bytes.starts_with(b"%!") || vectorcraft_pdf::is_postscript(&bytes) { return postscript_info(&bytes, p); @@ -229,7 +231,7 @@ fn postscript_info(bytes: &[u8], p: &Value) -> Result { /// A PNG of `doc`'s first artboard, `thumbnailSize` px (default 160, from 16 to 1024) on its longer /// side, base64. fn thumbnail(doc: &Document, p: &Value) -> Result { - let size = f64_or(p, "thumbnailSize", 160.0).clamp(16.0, 1024.0); + let size = number_or(p, "thumbnailSize", 160.0, INFO)?.clamp(16.0, 1024.0); let r = doc.artboards.first().map(|a| a.rect).ok_or_else(|| err("the page has no area"))?; let png = super::encode(doc, "png", &json!({"scale": size / r.width().max(r.height()).max(1.0)}))?; Ok(vectorcraft_format::base64_encode(&png)) diff --git a/crates/engine/src/cmd/fileio/progress.rs b/crates/engine/src/cmd/fileio/progress.rs index d2e039348..2d23ef6cd 100644 --- a/crates/engine/src/cmd/fileio/progress.rs +++ b/crates/engine/src/cmd/fileio/progress.rs @@ -14,7 +14,7 @@ use std::rc::Rc; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use vectorcraft_doc::cancel::{self, CancelToken, ProgressSink}; @@ -26,6 +26,9 @@ pub struct Progress { total: AtomicU64, /// The steps are files (Export for Screens), not artboards. files: bool, + /// The last report came from a plug-in ([`vectorcraft_plugins::host::PROGRESS_STEP`]): its + /// steps are its own, shown as a share of the work. + share: AtomicBool, } impl Progress { @@ -43,6 +46,11 @@ impl Progress { self.files } + /// Are the steps a plug-in's own, which only a share of the work done describes? + pub fn counts_share(&self) -> bool { + self.share.load(Ordering::Relaxed) + } + /// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go). pub fn counts(&self) -> (u64, u64) { let total = self.total.load(Ordering::Relaxed); @@ -68,7 +76,8 @@ impl Progress { /// Run `f` with `progress`'s token in effect on this thread and the steps reported to it. pub fn scope(progress: &Arc, f: impl FnOnce() -> R) -> R { let to = Arc::clone(progress); - let sink: ProgressSink = Rc::new(move |done, total, _| { + let sink: ProgressSink = Rc::new(move |done, total, what| { + to.share.store(what == vectorcraft_plugins::host::PROGRESS_STEP, Ordering::Relaxed); to.total.store(total, Ordering::Relaxed); to.done.store(done, Ordering::Relaxed); }); @@ -94,4 +103,14 @@ mod tests { assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{:?}", r.err()); assert!(!cancel::is_cancelled(), "the scope ended"); } + + /// A plug-in's reports count as a share of the work, not as artboards or files. + #[test] + fn a_plugin_s_reports_count_as_a_share_of_the_work() { + let p = Progress::new(); + scope(&p, || cancel::progress(1, 4, "artboard")); + assert_eq!((p.counts(), p.counts_share()), ((1, 4), false)); + scope(&p, || cancel::progress(3, 8, vectorcraft_plugins::host::PROGRESS_STEP)); + assert_eq!((p.counts(), p.counts_share()), ((3, 8), true)); + } } diff --git a/crates/engine/src/cmd/fileio/tests_affinity.rs b/crates/engine/src/cmd/fileio/tests_affinity.rs index 70b9b992a..05ca07019 100644 --- a/crates/engine/src/cmd/fileio/tests_affinity.rs +++ b/crates/engine/src/cmd/fileio/tests_affinity.rs @@ -98,7 +98,9 @@ fn templates_and_libraries_cannot_silently_extract_preview_content() { let original = s.doc().unwrap().doc.clone(); for name in ["test.af", "renamed.svg"] { let p = json!({"name":name, "dataBase64":vectorcraft_format::base64_encode(&file(&png()))}); - for cmd in ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary"] { + for cmd in + ["file.newFromTemplate", "swatch.library.load", "graphicStyle.loadLibrary", "charStyle.load", "paraStyle.load", "textStyle.loadAll"] + { let e = s.execute(cmd, &p).unwrap_err().to_string(); assert!(e.contains("File › Open"), "{cmd}: {e}"); } diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 605f82967..a38e35e82 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -488,7 +488,7 @@ fn an_ai_file_opens_from_its_vectorcraft_document_when_the_editing_copy_differs( let mut s = Session::new(); s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); let id = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap(); - s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "blur.gaussian", "ids": [id]})).unwrap(); let r = s.execute("document.export", &json!({"format": "ai"})).unwrap(); assert!(r["warnings"].to_string().contains("live effects"), "{r}"); let bytes = b64(&r); diff --git a/crates/engine/src/cmd/fileio/tests_pdfimport.rs b/crates/engine/src/cmd/fileio/tests_pdfimport.rs index f270c87b8..5ae8bd640 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfimport.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfimport.rs @@ -147,6 +147,76 @@ fn load_options_read_pages_crop_and_password() { assert_eq!(LoadOptions::from_params("x", &Value::Null).unwrap(), LoadOptions::default()); } +/// `cropTo` (and `crop`), `password`, `colorMode` and `textAs` given as anything but a string are an +/// error naming the param: `document.open` opens nothing and `file.place` places nothing and records +/// no undo step. Null is the same as leaving the param out. +#[test] +fn pdf_options_of_the_wrong_type_are_refused() { + let bytes = three_pages(); + let wrong = [ + ("cropTo", json!(5)), + ("crop", json!(true)), + ("password", json!(123)), + ("colorMode", json!(5)), + ("textAs", json!(true)), + ("textAs", json!(["outlines"])), + ]; + let one = |key: &str, v: &Value| { + let mut p = json!({}); + p[key] = v.clone(); + p + }; + let mut s = Session::new(); + for (key, v) in &wrong { + let e = open(&mut s, &bytes, one(key, v)).unwrap_err().to_string(); + assert!(e.contains(&format!("`{key}` must be a string")), "{key}: {e}"); + assert!(s.active().is_none(), "{key}: nothing opened"); + } + // A wrong `cropTo` is refused even when `crop` is a box, and a wrong `crop` when `cropTo` is. + let e = open(&mut s, &bytes, json!({"cropTo": 2, "crop": "art"})).unwrap_err().to_string(); + assert!(e.contains("`cropTo` must be a string"), "{e}"); + let e = open(&mut s, &bytes, json!({"cropTo": "art", "crop": 2})).unwrap_err().to_string(); + assert!(e.contains("`crop` must be a string"), "{e}"); + assert!(s.active().is_none(), "nothing opened"); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let nodes = s.doc().unwrap().doc.node_count(); + for (key, v) in &wrong { + let mut p = json!({"name": "pages.pdf", "dataBase64": b64(&bytes)}); + p[*key] = v.clone(); + let e = s.execute("file.place", &p).unwrap_err().to_string(); + assert!(e.contains(&format!("`{key}` must be a string")), "{key}: {e}"); + assert_eq!((s.doc().unwrap().history.undo.len(), s.doc().unwrap().doc.node_count()), (undo, nodes), "{key}: nothing placed"); + } + // Null is left out; valid strings read as before. + let nulls = json!({"cropTo": null, "crop": null, "password": null, "colorMode": null, "textAs": null}); + assert_eq!(LoadOptions::from_params("x", &nulls).unwrap(), LoadOptions::default()); + let o = LoadOptions::from_params("x", &json!({"cropTo": null, "crop": "Bleed", "colorMode": "CMYK", "textAs": "outlines"})).unwrap(); + assert_eq!( + (o.crop, o.color_mode, o.text_as), + (vectorcraft_pdf::CropTo::Bleed, Some(vectorcraft_doc::ColorMode::Cmyk), vectorcraft_pdf::TextAs::Outlines) + ); +} + +/// `document.pdfInfo`'s `thumbnailSize` given as anything but a number is an error, with or without +/// a `thumbnail`; null is 160 px. +#[test] +fn pdf_info_refuses_a_thumbnail_size_that_is_not_a_number() { + let bytes = three_pages(); + let mut s = Session::new(); + for v in [json!("100"), json!(true)] { + for p in [json!({"dataBase64": b64(&bytes), "thumbnail": 1, "thumbnailSize": v}), json!({"dataBase64": b64(&bytes), "thumbnailSize": v})] { + let e = s.execute("document.pdfInfo", &p).unwrap_err().to_string(); + assert!(e.contains("`thumbnailSize` must be a number"), "{v}: {e}"); + } + } + let e = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "colorMode": 1})).unwrap_err().to_string(); + assert!(e.contains("`colorMode` must be a string"), "{e}"); + let v = s.execute("document.pdfInfo", &json!({"dataBase64": b64(&bytes), "thumbnail": 1, "thumbnailSize": null})).unwrap(); + let png = vectorcraft_format::base64_decode(v["thumbnail"].as_str().unwrap()).unwrap(); + assert_eq!(image::load_from_memory(&png).unwrap().width(), 160); +} + #[test] fn a_postscript_ai_file_says_it_cannot_be_opened_yet() { // PostScript opens through the EPS reader now (tests_epsimport.rs): a program it can't read, diff --git a/crates/engine/src/cmd/flatten.rs b/crates/engine/src/cmd/flatten.rs index ccbe9c049..ba99c4beb 100644 --- a/crates/engine/src/cmd/flatten.rs +++ b/crates/engine/src/cmd/flatten.rs @@ -49,6 +49,8 @@ mod presets; mod report; pub(crate) use analysis::{outlined_text, see_through_images}; +#[cfg(test)] +pub(crate) use apply::with_every_region; pub(crate) use apply::{expand_transparent_tile_images, flatten_document, flatten_expanded, has_transparent_tile_images}; pub use commands::specs; pub use options::FlattenOptions; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index e4113b47b..2b0b1d072 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -150,9 +150,15 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg } // An unclipped full raster needs no regions. let whole = o.balance <= 0.0 && !o.clip_complex_regions; - let regions = if whole { vec![] } else { po::regions(&b.shapes) }; + // Nor does one that splits into more regions than the balance allows: the build stops at the + // first region past the limit. + let (regions, over) = match o.region_limit().filter(|_| !every_region()) { + _ if whole => (vec![], false), + Some(limit) => po::regions_within(&b.shapes, limit).map_or((vec![], true), |r| (r, false)), + None => (po::regions(&b.shapes), false), + }; // An arrangement that failed (degenerate geometry) leaves only rasterizing. - let all = regions.is_empty() || o.rasterize_all(regions.len()); + let all = over || regions.is_empty() || o.rasterize_all(regions.len()); let mut vector: Vec<(usize, RegionFill)> = vec![]; let mut complex: Vec = vec![]; let mut gradients_only = !all; @@ -195,6 +201,32 @@ fn flatten_group(plain: &[&Node], o: &FlattenOptions, cmyk: bool) -> Option<(Reg Some((vector, area)) } +#[cfg(test)] +thread_local! { + /// Groups flattened on this thread build all of their regions ([`with_every_region`]). + static EVERY_REGION: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Whether groups build all of their regions, past the balance's limit too. +#[cfg(test)] +fn every_region() -> bool { + EVERY_REGION.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn every_region() -> bool { + false +} + +/// `f` with every group it flattens building all of its regions, past the balance's limit too. +#[cfg(test)] +pub(crate) fn with_every_region(f: impl FnOnce() -> T) -> T { + EVERY_REGION.with(|e| e.set(true)); + let out = f(); + EVERY_REGION.with(|e| e.set(false)); + out +} + /// The flat colour of a region covered by `sources` (CMYK in a `cmyk` document); `None` where /// nothing paints it. fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool) -> Option { diff --git a/crates/engine/src/cmd/flatten/options.rs b/crates/engine/src/cmd/flatten/options.rs index 52a900289..a5881c76e 100644 --- a/crates/engine/src/cmd/flatten/options.rs +++ b/crates/engine/src/cmd/flatten/options.rs @@ -169,9 +169,16 @@ impl FlattenOptions { } /// Whether a group that splits into `regions` atomic regions is rasterized whole. - pub(super) fn rasterize_all(&self, regions: usize) -> bool { + pub(crate) fn rasterize_all(&self, regions: usize) -> bool { self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0)) } + + /// The most atomic regions a group can split into without being rasterized whole, when a group + /// with more needs none of its regions: `None` at a balance of 0 or 100, and with Clip Complex + /// Regions (a group rasterized whole is clipped to all of its regions). + pub(crate) fn region_limit(&self) -> Option { + (!self.clip_complex_regions && self.balance > 0.0 && self.balance < 100.0).then(|| 10f64.powf(self.balance / 20.0).floor() as usize) + } } pub(super) fn flatten(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/layerpanel.rs b/crates/engine/src/cmd/layerpanel.rs index cf5887074..0a81a947e 100644 --- a/crates/engine/src/cmd/layerpanel.rs +++ b/crates/engine/src/cmd/layerpanel.rs @@ -10,7 +10,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use super::layer::{NewLayers, row_roots, rows_param}; -use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, id_param, ok, switch_or}; +use super::{CommandSpec, Result, Session, bad, checked_id, cmd, has_doc, object_param, ok, switch_or}; use crate::EngineError; pub fn specs() -> Vec { @@ -336,7 +336,7 @@ fn release(s: &mut Session, p: &Value) -> Result { const C: &str = "layer.releaseToLayers"; let build = switch_or(p, "build", false, C)?; let st = s.doc()?; - let id = match id_param(p, "id") { + let id = match object_param(p, "id", C)? { Some(id) => id, None => match st.highlighted_rows().as_slice() { [id] => *id, @@ -434,8 +434,9 @@ fn reverse(s: &mut Session, p: &Value) -> Result { } fn locate(s: &mut Session, p: &Value) -> Result { + let id = object_param(p, "id", "layer.locate")?; let st = s.doc_mut()?; - let id = id_param(p, "id").or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?; + let id = id.or_else(|| st.selection.objects.last().copied()).ok_or_else(|| bad("layer.locate", "nothing selected"))?; let ancestry = st.doc.ancestry(id).ok_or(EngineError::NoNode(id))?; st.layer_rows = vec![id]; st.revision += 1; diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index bcda11c2b..89492ad01 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -24,14 +24,15 @@ use std::borrow::Cow; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Component, Path}; +use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions}; -use vectorcraft_geom::{Affine, Rect, shapes}; +use vectorcraft_geom::{Affine, PathData, Rect, shapes}; use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, id_param, ids_param, str_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, ids_param, object_param, str_param}; pub fn specs() -> Vec { vec![ @@ -49,7 +50,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}", has_doc, update ), @@ -67,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, copy?: false (with path, 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)}", has_doc, relink ), @@ -483,23 +484,183 @@ fn read_for(f: &mut Found, g: &Group, cmd: &str) -> Result { Ok(Read::Document(f.link(&bytes, &g.link), src)) } -/// Objects `ids` show what was read (see [`install`], [`install_document`]). -fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option) -> Result<()> { +/// Objects `ids` show what was read (see [`install`], [`install_document`]), and with `also`, the +/// objects outside the layer tree that show group `also`'s content. +fn install_read(d: &mut Document, ids: &[NodeId], read: Read, force: Option, also: Option<&Group>) -> Result<()> { + let also = also.map(|g| (g.link.path.as_str(), g.key.as_str())); match read { - Read::Image(r) => install(d, ids, r, force), - Read::Document(link, src) => install_document(d, ids, &link, &src, force), + Read::Image(r) => install(d, ids, r, force, also), + Read::Document(link, src) => install_document(d, ids, &link, &src, force, also), + } +} + +/// Is `n` a linked image (or, with `document`, a placed document) of the file at `path` showing +/// content `key`? +fn shows(n: &Node, (path, key): (&str, &str), document: bool) -> bool { + match &n.kind { + NodeKind::Image(im) => !document && im.key == key && im.link.as_ref().is_some_and(|l| l.path == path), + NodeKind::PlacedDocument(p) => document && p.key == key && p.link.path == path, + _ => false, + } +} + +/// The clipping path of `clip`: its room as a rectangle in its frame. +fn clip_shape((frame, room): Clip) -> PathData { + shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame) +} + +/// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and +/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. +struct Nested<'a> { + pick: &'a dyn Fn(&Node) -> bool, + /// The object an object becomes, and the clip it takes ([`placed_xf`]). + make: &'a mut dyn FnMut(&Node) -> (Node, Option), + /// The next node id for a clip group (taken from the document after the walk). + next: u64, +} + +impl Nested<'_> { + /// Every root of `d`: the layers (whose own objects are left alone), then the art of symbols, + /// graph designs and patterns. + fn run(mut self, d: &mut Document) { + let first = d.peek_next_id(); + self.next = first; + for l in d.layers.iter_mut() { + self.update(l, false); + } + for root in d.symbols.iter_mut().map(|s| &mut s.art).chain(d.graph_designs.iter_mut().map(|g| &mut g.art)) { + self.root(root); + } + for root in d.patterns.iter_mut().flat_map(|p| &mut p.art) { + self.root(root); + } + for _ in first..self.next { + d.alloc_id(); + } + } + + /// The art at a root outside the layer tree (a symbol's, graph design's or pattern's art, or + /// mask art). + fn root(&mut self, n: &mut Arc) { + if !(self.pick)(n) { + return self.update(n, true); + } + let (new, clip) = (self.make)(n); + let mut new = Arc::new(new); + self.update(&mut new, true); + *n = match clip { + Some(clip) => Arc::new(self.clip_group(new, clip)), + None => new, + }; + } + + /// `n` (outside the layer tree when `nested`) with the picked objects under it remade; it stays + /// the same node when none is there, and only the nodes on the way to one are copied. + fn update(&mut self, n: &mut Arc, nested: bool) { + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + self.root(&mut art); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut slots: Vec<(usize, Arc)> = vec![]; + if let Some(children) = n.children() { + // A clip group holding just the object: its clipping path changes. + let clip_path = match (&n.kind, &children[..]) { + (NodeKind::Group { clip: true, .. }, [c, _]) if matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c), + _ => None, + }; + for (i, c) in children.iter().enumerate() { + if !(nested && (self.pick)(c)) { + let mut new = Arc::clone(c); + self.update(&mut new, nested); + if !Arc::ptr_eq(&new, c) { + slots.push((i, new)); + } + continue; + } + let (new, clip) = (self.make)(c); + let mut new = Arc::new(new); + self.update(&mut new, true); + match (clip, clip_path) { + (None, _) => slots.push((i, new)), + (Some(clip), Some(path)) if i == 1 => { + let mut path = Node::clone(path); + if let NodeKind::Path { path: p, .. } = &mut path.kind { + *p = clip_shape(clip); + } + slots.extend([(0, Arc::new(path)), (1, new)]); + } + (Some(clip), _) => { + let group = self.clip_group(new, clip); + slots.push((i, Arc::new(group))); + } + } + } + } + if mask.is_none() && slots.is_empty() { + return; + } + let n = Arc::make_mut(n); + if let (Some(art), Some(m)) = (mask, n.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(children) = n.children_mut() { + for (i, c) in slots { + if let Some(slot) = children.get_mut(i) { + *slot = c; + } + } + } + } + + /// A clip group of `object` clipped to `clip`, as [`clip_image`] makes one in the layers. + fn clip_group(&mut self, object: Arc, clip: Clip) -> Node { + let (path_id, group_id) = (NodeId(self.next), NodeId(self.next + 1)); + self.next += 2; + let none = vectorcraft_color::Paint::None; + let mut path = Node::path(path_id, clip_shape(clip), Appearance::basic(none.clone(), none, 0.0)); + if let NodeKind::Path { clipping, .. } = &mut path.kind { + *clipping = true; + } + Node::new(group_id, NodeKind::Group { children: vec![Arc::new(path), object], clip: true }) } } /// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its -/// Placement Options say (`force`: as that Preserve choice says, whatever they say). -fn install_document(d: &mut Document, ids: &[NodeId], link: &LinkInfo, src: &super::place::document::Source, force: Option) -> Result<()> { +/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also` +/// (a file's path and the content shown), so do the placed documents outside the layer tree that +/// show that content. +fn install_document( + d: &mut Document, + ids: &[NodeId], + link: &LinkInfo, + src: &super::place::document::Source, + force: Option, + also: Option<(&str, &str)>, +) -> Result<()> { let mut clips = vec![]; let (w, h) = (src.frame.width(), src.frame.height()); + let placed = |p: &PlacedDocument| { + let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; + placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o) + }; + if let Some(also) = also { + let pick = |n: &Node| shows(n, also, true); + let mut make = |n: &Node| { + let mut n = n.clone(); + let mut clip = None; + if let NodeKind::PlacedDocument(p) = &mut n.kind { + let (xf, c) = placed(p); + (p.key, p.width, p.height, p.xf, p.link) = (src.key.clone(), w, h, xf, link.clone()); + clip = c; + } + (n, clip) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } for id in ids { let Some(NodeKind::PlacedDocument(p)) = d.node(*id).map(|n| &n.kind) else { continue }; - let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; - let (xf, clip) = placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o); + let (xf, clip) = placed(p); if let Some(n) = d.node_mut(*id) && let NodeKind::PlacedDocument(p) = &mut n.kind { @@ -565,8 +726,8 @@ fn placed_xf(xf: Affine, old: (f64, f64), old_pt: (f64, f64), new: (f64, f64), n /// Clip image `id` of `d` to `room` in `frame`: the clipping path of the clip group holding just /// it changes, else the image goes into a new clip group. -fn clip_image(d: &mut Document, id: NodeId, (frame, room): Clip) -> Result<()> { - let path = shapes::rectangle(Rect::new(0.0, 0.0, room.0, room.1)).transformed(frame); +fn clip_image(d: &mut Document, id: NodeId, clip: Clip) -> Result<()> { + let path = clip_shape(clip); let clip_path = d.parent_of(id).and_then(|p| match &d.node(p)?.kind { NodeKind::Group { children, clip: true } => match &children[..] { [c, im] if im.id == id && matches!(c.kind, NodeKind::Path { clipping: true, .. }) => Some(c.id), @@ -602,14 +763,33 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ let path = fileio::document_path(path, doc_path).map_err(|r| EngineError::Other(format!("the image `{path}` isn't followed: {r}")))?; let bytes = read_file(&path)?; let read = (link_info(&path, &bytes), linked_image(&bytes)?); - install(d, &[id], read, Some(Preserve::Bounds)) + install(d, &[id], read, Some(Preserve::Bounds), None) } /// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement -/// Options say (`force`: as that Preserve choice says, whatever they say). -fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option) -> Result<()> { +/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's +/// path and the content shown), so do the images outside the layer tree that show that content. +fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option, also: Option<(&str, &str)>) -> Result<()> { let new_pt = super::place::pt_per_px(img.ppi); let mut clips = vec![]; + if let Some(also) = also { + // Every image picked shows content `also.1`, so one resolution serves them all. + let old_pt = file_pt(d, also.1); + let pick = |n: &Node| shows(n, also, false); + let mut make = |n: &Node| { + let mut n = n.clone(); + let mut clip = None; + if let NodeKind::Image(im) = &mut n.kind { + let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; + let size = |w: u32, h: u32| (w as f64, h as f64); + let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); + (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); + clip = c; + } + (n, clip) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } for id in ids { let Some(NodeKind::Image(im)) = d.node(*id).map(|n| &n.kind) else { continue }; let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; @@ -678,6 +858,9 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p // of other keys linking to the same file share them, rather than each reading, decoding and // keeping a copy of its own. let mut read_images: BTreeMap = BTreeMap::new(); + // (the path as it was, the content shown, a placed document?, the link now) of each file found + // away from its path or read again. + let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![]; for g in groups(d, None) { let row = rows(std::iter::once(&g), doc_path); let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key); @@ -685,7 +868,8 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p match probe(&g, doc_path) { (Status::Missing, _) => out.missing.extend(row), (Status::Modified, Some(mut f)) if update || unread => { - match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, unread.then_some(Preserve::Bounds))) { + let force = unread.then_some(Preserve::Bounds); + match read_for(&mut f, &g, "document.open").and_then(|read| install_read(d, &g.ids, read, force, Some(&g))) { Ok(()) if unread => {} Ok(()) => out.updated.extend(row), Err(_) => out.missing.extend(row), @@ -697,7 +881,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p let read = if stale(d, &g) { Some(read_for(&mut f, &g, "document.open")) } else { None }; match read { Some(Ok(read)) => { - if install_read(d, &g.ids, read, Some(Preserve::Bounds)).is_err() { + if install_read(d, &g.ids, read, Some(Preserve::Bounds), Some(&g)).is_err() { out.missing.extend(row); } } @@ -709,6 +893,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p }; if link != g.link { set_links(d, &g.ids, &link); + moved.push((g.link.path.clone(), g.key.clone(), true, link)); } } } @@ -720,6 +905,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p let link = f.link_hashed(hash, &g.link); if link != g.link { set_links(d, &g.ids, &link); + moved.push((g.link.path.clone(), g.key.clone(), false, link)); } continue; } @@ -745,11 +931,34 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p }; if link != g.link { set_links(d, &g.ids, &link); + moved.push((g.link.path.clone(), g.key.clone(), false, link)); } } (Status::Ok, None) => {} } } + // The objects in symbols, graph designs, patterns and opacity masks that show the same file and + // content, which have no ids here, whatever else their links hold. + if !moved.is_empty() { + let mut by_path: BTreeMap> = BTreeMap::new(); + for (path, key, document, new) in moved { + by_path.entry(path).or_default().push((key, document, new)); + } + let new = |n: &Node| { + let (l, key, document) = linked(n)?; + let m = by_path.get(&l.path)?; + m.iter().find(|(k, doc, _)| k == key && *doc == document.is_some()).map(|(_, _, new)| new) + }; + let pick = |n: &Node| new(n).is_some(); + let mut make = |n: &Node| { + let mut out = n.clone(); + if let (Some(link), Some(l)) = (new(n), link_mut(&mut out)) { + *l = link.clone(); + } + (out, None) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } out } @@ -772,35 +981,31 @@ fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open -/// documents: read again from the new file, each as one Update Links step of its document. +/// documents, in the layers, symbols, graph designs, patterns and opacity masks: read again from +/// the new file, each as one Update Links step of its document. pub(crate) fn refresh_placed(s: &mut Session, saved: u64, path: &str) { let path = absolute_path(path); - let targets: Vec<(usize, Vec)> = s + let targets: Vec = s .documents() .iter() .enumerate() .filter(|(_, st)| st.uid != saved) - .filter_map(|(i, st)| { - let mut ids = vec![]; - st.doc.walk(|n| { - if let NodeKind::PlacedDocument(p) = &n.kind - && p.link.path == path - { - ids.push(n.id.0); - } - }); - (!ids.is_empty()).then_some((i, ids)) + .filter(|(_, st)| { + let mut placed = false; + st.doc.visit_placed(|_, p| placed |= p.link.path == path); + placed }) + .map(|(i, _)| i) .collect(); if targets.is_empty() { return; } // Each document is updated as the active one, without switching tools. let back = s.active; - for (i, ids) in targets { + for i in targets { s.active = Some(i); // A file that can't be read leaves what its objects show: links.check reports it. - let _ = update(s, &json!({ "ids": ids })); + let _ = update_where(s, None, Some(&path)); } s.active = back; } @@ -826,31 +1031,45 @@ fn relative_path(target: &Path, base: &Path) -> Option { } /// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they -/// are already. +/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set. pub fn with_relative_paths(d: &Document, dest: &str) -> Option { let dest = absolute_path(dest); let dir = Path::new(&dest).parent()?; - let mut changes = vec![]; - d.walk(|n| { - if let Some((l, _, _)) = linked(n) { - let r = relative_path(Path::new(&l.path), dir); - if r != l.relative { - changes.push((n.id, r)); - } - } - }); - if changes.is_empty() { + let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir); + let outdated = |l: &LinkInfo| relative(l) != l.relative; + let mut changed = false; + d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated)); + d.visit_placed(|_, p| changed |= outdated(&p.link)); + if !changed { return None; } let mut d = d.clone(); - for (id, r) in changes { - if let Some(l) = d.node_mut(id).and_then(link_mut) { - l.relative = r; - } - } + d.update_links_where(outdated, |l| l.relative = relative(l)); Some(d) } +/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own, +/// to the path where that document finds its file, so the copy finds the same files. +/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set. +pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) { + // The new path of each link by what `locate` reads of it: its path, relative path and name. + let mut found: BTreeMap<(String, Option), Option> = BTreeMap::new(); + let mut look = |l: &LinkInfo| { + found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path)); + }; + d.visit_images(|_, im| im.link.iter().for_each(&mut look)); + d.visit_placed(|_, p| look(&p.link)); + found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path)); + if found.is_empty() { + return; + } + d.update_links(|l| { + if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) { + l.path = f.clone(); + } + }); +} + // ---------- commands ---------- fn check(s: &mut Session, p: &Value) -> Result { @@ -876,28 +1095,92 @@ fn check(s: &mut Session, p: &Value) -> Result { } fn update(s: &mut Session, p: &Value) -> Result { + update_where(s, ids_param(p, "ids"), None) +} + +/// Update Links in the active document of `s` for objects `ids` (default: the linked files that +/// changed or show their preview), or with `path`, for the placed documents of the file at `path` +/// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that +/// show the same content are read again too, and have no ids to report. +fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; - let ids = ids_param(p, "ids"); let (mut reads, mut missing) = (vec![], vec![]); - for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| !g.ids.is_empty()) { + let asked = ids.is_some() || path.is_some(); + for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) { match probe(&g, st.path.as_deref()) { (Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)), - // Unchanged and showing the file: nothing to read unless asked for by id. - (Status::Ok, Some(_)) if ids.is_none() && !stale(&st.doc, &g) => {} + // Unchanged and showing the file: nothing to read unless asked for. + (Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {} // Unreadable as an image (or a document) counts as missing. (_, Some(mut f)) => match read_for(&mut f, &g, "links.update") { - Ok(read) => reads.push((g.ids, read)), + Ok(read) => reads.push((g, read)), Err(_) => missing.extend(ids_json(&g.ids)), }, } } - let updated: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); + let updated: Vec = reads.iter().flat_map(|(g, _)| ids_json(&g.ids)).collect(); if !reads.is_empty() { - s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, None)))?; + s.edit("Update Links", |d, _| reads.into_iter().try_for_each(|(g, read)| install_read(d, &g.ids, read, None, Some(&g))))?; } Ok(json!({ "updated": updated, "missing": missing })) } +/// Which links `links.relink` points at the new file (the Replace choice of Relink). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Scope { + /// Each link its own file (`paths`). + Each, + /// The objects given or selected. + Selected, + /// Every object in the layers linked to the same file as one given or selected. + All, + /// The objects given or selected, then every other missing link whose file name is in the new + /// file's folder. + MissingInFolder, +} + +impl Scope { + fn from_id(id: &str) -> Option { + match id { + "each" => Some(Scope::Each), + "selected" => Some(Scope::Selected), + "all" => Some(Scope::All), + "missingInFolder" => Some(Scope::MissingInFolder), + _ => None, + } + } +} + +/// The error for `scope` and `paths` given one without the other, or `paths` beside ids, path or +/// folder. +fn each_mismatch(cmd: &str) -> EngineError { + bad(cmd, "scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder") +} + +/// `links.relink {scope: "each", paths}`: each entry's ids with its absolute file, entries naming +/// one file merged. A malformed entry, an id in two entries or an empty list is an error. +fn each_paths(p: &Value, cmd: &str) -> Result, String)>> { + let entries = p.get("paths").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(|| each_mismatch(cmd))?; + let mut seen = BTreeSet::new(); + let mut out: Vec<(Vec, String)> = vec![]; + for (i, e) in entries.iter().enumerate() { + let malformed = || bad(cmd, format!("paths entry {i} needs path and ids (object ids no other entry has)")); + let path = e.get("path").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or_else(malformed)?; + let ids = e.get("ids").and_then(Value::as_array).filter(|a| !a.is_empty()).ok_or_else(malformed)?; + let mut these = vec![]; + for v in ids { + let id = v.as_u64().map(NodeId).filter(|id| seen.insert(*id)).ok_or_else(malformed)?; + these.push(id); + } + let path = absolute_path(path); + match out.iter_mut().find(|(_, p)| *p == path) { + Some((ids, _)) => ids.extend(these), + None => out.push((these, path)), + } + } + Ok(out) +} + fn relink(s: &mut Session, p: &Value) -> Result { const C: &str = "links.relink"; // How the new file takes the image's place this time (default: its Placement Options). @@ -905,13 +1188,26 @@ fn relink(s: &mut Session, p: &Value) -> Result { None => None, Some(v) => Some(Preserve::from_id(v).ok_or_else(|| bad(C, format!("preserve `{v}` is not transforms|bounds|fileDimensions|fit|fill")))?), }; + let scope = match p.get("scope").filter(|v| !v.is_null()) { + None => Scope::Selected, + Some(v) => v.as_str().and_then(Scope::from_id).ok_or_else(|| bad(C, format!("scope {v} is not each|selected|all|missingInFolder")))?, + }; let st = s.doc()?; let folder_param = str_param(p, "folder"); + match scope { + Scope::Each if p.get("path").is_some() || folder_param.is_some() || p.get("ids").is_some() => return Err(each_mismatch(C)), + Scope::Selected | Scope::All | Scope::MissingInFolder if p.get("paths").is_some() => return Err(each_mismatch(C)), + Scope::MissingInFolder if folder_param.is_some() => { + return Err(bad(C, "scope missingInFolder takes path: the folder of that file is searched for the other missing links")); + } + _ => {} + } + let each = if scope == Scope::Each { Some(each_paths(p, C)?) } else { None }; // With copy: the saved document's folder, which the file is copied into. - let copy_to = match (bool_or(p, "copy", false), str_param(p, "path")) { + let copy_to = match (bool_or(p, "copy", false), str_param(p, "path").is_some() || each.is_some()) { (false, _) => None, - (true, None) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")), - (true, Some(_)) => { + (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")))?) } @@ -924,9 +1220,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { _ => None, }) }; - let ids: Vec = match ids_param(p, "ids") { - Some(ids) => ids, - None => { + let mut ids: Vec = match (&each, ids_param(p, "ids")) { + (Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(), + (None, Some(ids)) => ids, + (None, None) => { let selected: Vec = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect(); match folder_param { Some(_) if selected.is_empty() => { @@ -942,15 +1239,37 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } + // All Instances: the other objects in the layers linked to the same files. + if scope == Scope::All { + let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); + let given: BTreeSet = ids.iter().copied().collect(); + let mut more = vec![]; + st.doc.walk(|n| { + if let Some((l, _, _)) = linked(n) + && paths.contains(l.path.as_str()) + && !given.contains(&n.id) + { + more.push(n.id); + } + }); + ids.extend(more); + } if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. let mut files: Vec<(Vec, String)> = vec![]; let mut not_found = vec![]; - match (str_param(p, "path"), folder_param) { - (Some(path), _) => files.push((ids, absolute_path(path))), - (None, Some(dir)) => { + match (each, str_param(p, "path"), folder_param) { + (Some(each), _, _) => files = each, + (None, Some(path), _) if scope == Scope::MissingInFolder => { + let path = absolute_path(path); + let given: BTreeSet = ids.iter().copied().collect(); + files.push((ids, path)); + missing_in_folder(st, &given, copy_to.is_some(), &mut files, &mut not_found); + } + (None, Some(path), _) => files.push((ids, absolute_path(path))), + (None, None, Some(dir)) => { for id in ids { let Some(link) = image(&id).flatten() else { continue }; let path = absolute_path(&Path::new(dir).join(link.name()).to_string_lossy()); @@ -961,10 +1280,10 @@ fn relink(s: &mut Session, p: &Value) -> Result { } } } - (None, None) => return Err(bad(C, "give path (a file) or folder")), + (None, None, None) => return Err(bad(C, "give path (a file) or folder")), } - let mut reads = vec![]; - let (mut copied, mut kept) = (vec![], vec![]); + // Every file is read and checked before any is copied. + let mut checked = vec![]; for (ids, path) in files { let bytes = read_file(&path)?; // Images show the file; placed documents each show their artboard or page of it. @@ -988,20 +1307,28 @@ fn relink(s: &mut Session, p: &Value) -> Result { for ((page, bounding, page_box), ids) in documents { sources.push((page, ids, super::place::document::source(&path, &bytes, Some(&path), page, bounding, page_box, C)?)); } - let to = match ©_to { - Some(dir) => { - let (to, new) = copy_into_folder(&path, &bytes, dir)?; + // The bytes are kept only to be copied, the file as it was checked. + let link = link_info(&path, &bytes); + let bytes = copy_to.is_some().then_some(bytes); + checked.push((path, link, bytes, images, img, sources)); + } + let mut reads = vec![]; + let (mut copied, mut kept): (Vec, Vec) = (vec![], vec![]); + for (path, link, bytes, images, img, sources) in checked { + let link = match (©_to, bytes) { + (Some(dir), Some(bytes)) => { + let (to, new) = copy_or_remove(&path, &bytes, dir, &copied)?; + let link = link_info(&to, &bytes); let pair = json!({ "from": path, "to": to }); if new { copied.push(pair) } else { kept.push(pair) } - to + link } - None => path, + _ => link, }; - let link = link_info(&to, &bytes); if let Some(img) = img { reads.push((images, Read::Image((link.clone(), img)))); } @@ -1011,7 +1338,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { } let relinked: Vec = reads.iter().flat_map(|(ids, _)| ids_json(ids)).collect(); if !reads.is_empty() { - s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve)))?; + s.edit("Relink", |d, _| reads.into_iter().try_for_each(|(ids, read)| install_read(d, &ids, read, preserve, None)))?; } not_found.sort(); not_found.dedup(); @@ -1023,6 +1350,41 @@ fn relink(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// `links.relink {scope: "missingInFolder"}`: the other missing links of `st` (not `given`; only +/// images when `images_only`, as copy takes), each added to `files` with the file of its name in +/// the folder of the first file there; names not there go to `not_found`. +fn missing_in_folder( + st: &crate::DocState, + given: &BTreeSet, + images_only: bool, + files: &mut Vec<(Vec, String)>, + not_found: &mut Vec, +) { + let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return }; + for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) { + let rest: Vec = g.ids.iter().copied().filter(|id| !given.contains(id)).collect(); + if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing { + continue; + } + let found = absolute_path(&dir.join(g.link.name()).to_string_lossy()); + match files.iter_mut().find(|(_, p)| *p == found) { + Some((ids, _)) => ids.extend(rest), + None if file_stamp(&found).is_some() => files.push((rest, found)), + None => not_found.push(g.link.name().to_string()), + } + } +} + +/// [`copy_into_folder`]; when it fails, the copies `copied` (`{from, to}`) the same call made +/// before are removed, since nothing is relinked. +fn copy_or_remove(from: &str, bytes: &[u8], dir: &Path, copied: &[Value]) -> Result<(String, bool)> { + copy_into_folder(from, bytes, dir).inspect_err(|_| { + for to in copied.iter().filter_map(|pair| pair.get("to").and_then(Value::as_str)) { + let _ = std::fs::remove_file(to); + } + }) +} + /// `links.relink {copy}`: put the file at `from` (`bytes`, as just read) into the folder `dir`, /// never replacing a file there ([`fileio::copy_new`]) → where it is now, and whether it was /// copied (`false`: a file there with the same contents, of its name or a numbered one, is used). @@ -1071,7 +1433,9 @@ fn selected_images(st: &crate::DocState, keep: impl Fn(&ImageObject) -> bool) -> /// `id`, else the first selected image. fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { - let id = id_param(p, "id").or_else(|| selected_images(st, |_| true).first().copied()).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; + let id = object_param(p, "id", cmd)? + .or_else(|| selected_images(st, |_| true).first().copied()) + .ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match image_of(&st.doc, id) { Some(_) => Ok(id), None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), @@ -1100,7 +1464,7 @@ fn placement_of(d: &Document, id: NodeId) -> Option { /// `id`, else the first selected image or placed document. fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { let first = || st.selection.objects.iter().copied().find(|id| placement_of(&st.doc, *id).is_some()); - let id = id_param(p, "id").or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; + let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match placement_of(&st.doc, id) { Some(_) => Ok(id), None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), @@ -1270,7 +1634,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { if !done.is_empty() || !documents.is_empty() { s.edit("Embed", |d, _| { for (ids, read) in reads { - install(d, &ids, read, Some(Preserve::Bounds))?; + install(d, &ids, read, Some(Preserve::Bounds), None)?; } for id in &done { if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { @@ -1327,7 +1691,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { let img = linked_image(&bytes)?; write_file(&path, &bytes)?; let read = (link_info(&path, &bytes), img); - s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds)))?; + s.edit("Unembed", |d, _| install(d, &[id], read, Some(Preserve::Bounds), None))?; Ok(json!({ "id": id.0, "path": path })) } diff --git a/crates/engine/src/cmd/live.rs b/crates/engine/src/cmd/live.rs index be02c16b1..464f24115 100644 --- a/crates/engine/src/cmd/live.rs +++ b/crates/engine/src/cmd/live.rs @@ -20,8 +20,8 @@ use super::edit::selected_roots; use super::expand::gradient_sampler; use super::pathops::Beside; use super::{ - CommandSpec, EngineError, Result, Session, always, bad, check_numbers, check_switches, cmd, color_param, f64_or, f64_req, finite_numbers, - has_doc, has_multi, has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, + CommandSpec, EngineError, Result, Session, always, bad, check_numbers, cmd, color_param, f64_or, f64_req, finite_numbers, has_doc, has_multi, + has_selection, id_param, named_ids, number_or, ok, str_param, switch_or, text_param, }; mod blend; diff --git a/crates/engine/src/cmd/live/blend.rs b/crates/engine/src/cmd/live/blend.rs index 9933b2071..7d7c21227 100644 --- a/crates/engine/src/cmd/live/blend.rs +++ b/crates/engine/src/cmd/live/blend.rs @@ -7,7 +7,7 @@ use super::common::{ use super::mesh::index_param; use super::{ Affine, Anchor, Appearance, Arc, Beside, BlendDefaults, BlendOrientation, BlendSpacing, BlendSpec, Document, EngineError, Node, NodeId, NodeKind, - Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, id_param, json, live, number_or, ok, str_param, switch_or, + Paint, PathData, Point, Result, Session, Spine, Value, Vec2, bad, f64_req, json, live, number_or, ok, str_param, switch_or, }; /// The first blend among `roots`. @@ -160,7 +160,7 @@ fn edit_spine( label: &str, f: impl FnOnce(&mut vectorcraft_geom::SubPath, &mut Vec, &mut [Arc]) -> Result, ) -> Result { - let id = match id_param(p, "id") { + let id = match crate::cmd::object_param(p, "id", cmd)? { Some(id) => id, None => *selected_of(s, is_blend).first().ok_or_else(|| bad(cmd, "select a blend or give `id`"))?, }; diff --git a/crates/engine/src/cmd/live/commands.rs b/crates/engine/src/cmd/live/commands.rs index 20dd94fb3..54c65b0c7 100644 --- a/crates/engine/src/cmd/live/commands.rs +++ b/crates/engine/src/cmd/live/commands.rs @@ -136,7 +136,7 @@ pub fn specs() -> Vec { "Make with Warp…", ["Object", "Envelope Distort"], Some("Cmd+Alt+Shift+W"), - "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected) → {id}", + "{ids?, style?: arc|arcLower|arcUpper|arch|bulge|shellLower|shellUpper|flag|wave|fish|rise|fisheye|inflate|squeeze|twist (arc), bend?: % -100..100 (50), h?: horizontal distortion % -100..100 (0), v?: vertical distortion % (0), horizontal?: bool (true) | orientation?: horizontal|vertical, horizontalDistortion?: as h, verticalDistortion?: as v} envelope the selected objects with a warp (Envelope Options from object.envelope.options with nothing selected; type maps from its glyph outline bounds) → {id}", has_selection, env_make_warp ), @@ -145,7 +145,7 @@ pub fn specs() -> Vec { "Make with Mesh…", ["Object", "Envelope Distort"], Some("Cmd+Alt+M"), - "{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches → {id}", + "{ids?, rows?: 1..50 (4), cols?: 1..50 (4), columns?: as cols} envelope the selected objects with a mesh of rows × cols patches over their bounds (type measured by its glyph outlines) → {id}", has_selection, env_make_mesh ), @@ -154,7 +154,7 @@ pub fn specs() -> Vec { "Make with Top Object", ["Object", "Envelope Distort"], Some("Cmd+Alt+C"), - "{ids?} the topmost selected path becomes the envelope of the others → {id}", + "{ids?} the topmost selected path becomes the envelope of the others (type maps from its glyph outline bounds) → {id}", has_multi, env_make_top ), @@ -172,7 +172,7 @@ pub fn specs() -> Vec { "Reset with Mesh…", ["Object", "Envelope Distort"], None, - "{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content", + "{rows?: 1..50, cols?: 1..50 (unset: a mesh envelope's own, else 4), maintainShape?: bool (true: the mesh follows the current envelope shape; false: a flat grid over the content, type measured by its glyph outlines), columns?: as cols} make the selected envelopes mesh envelopes, keeping their content", has_envelope, env_reset_mesh ), diff --git a/crates/engine/src/cmd/live/envelope.rs b/crates/engine/src/cmd/live/envelope.rs index 502ab0a7d..521f092b0 100644 --- a/crates/engine/src/cmd/live/envelope.rs +++ b/crates/engine/src/cmd/live/envelope.rs @@ -1,9 +1,9 @@ +use super::super::object::choice; use super::common::{fix_ids, ids_json, is_envelope, roots_param, selected_of, wrap}; use super::mesh::{handle_param, index_param, whole}; use super::{ Affine, Appearance, Arc, Beside, Color, Document, EngineError, EnvelopeKind, EnvelopeMap, EnvelopeOptions, Node, NodeId, NodeKind, Paint, - PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, check_switches, f64_or, f64_req, id_param, json, live, ok, str_param, - switch_or, + PathData, Point, PreserveShape, Result, Session, Value, bad, check_numbers, f64_or, f64_req, id_param, json, live, ok, switch_or, text_param, }; // ---------- Envelope ---------- @@ -17,7 +17,7 @@ fn warp_kind(p: &Value, cmd: &str, cur: Option<&EnvelopeKind>) -> Result) -> Result false, - Some("horizontal") => true, - _ => given, + horizontal = match choice(p, cmd, "orientation", &["horizontal", "vertical"])? { + Some(o) => o == "horizontal", + None => given, }; Ok(EnvelopeKind::Warp { style, bend, h, v, horizontal }) } @@ -85,7 +84,7 @@ pub(super) fn env_make_mesh(s: &mut Session, p: &Value) -> Result { let roots = roots_param(s, p)?; let st = s.doc()?; let nodes: Vec> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).map(Arc::new).collect(); - let src = live::nodes_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; + let src = live::envelope_content_bounds(&nodes).ok_or_else(|| bad(C, "selection has no bounds"))?; let kind = EnvelopeKind::Mesh { rows, cols, points: live::grid_points(src, rows, cols), handles: vec![] }; let defaults = envelope_defaults(s); let id = s.edit("Make Envelope", |d, sel| wrap(d, sel, &roots, |id, content| envelope(id, content, kind, defaults)))?; @@ -185,7 +184,7 @@ fn envelope_options_json(mut v: Value, o: &EnvelopeOptions, fidelity: f64) -> Va /// `cur` with the options `p` sets. fn options_param(p: &Value, cmd: &str, cur: EnvelopeOptions) -> Result { - let preserve_shape = match str_param(p, "preserveShape") { + let preserve_shape = match text_param(p, "preserveShape", cmd)? { Some(v) => PreserveShape::parse(v).ok_or_else(|| bad(cmd, "preserveShape must be clippingMask|transparency"))?, None => cur.preserve_shape, }; @@ -289,8 +288,8 @@ pub(super) fn env_release(s: &mut Session, _: &Value) -> Result { pub(super) fn env_options(s: &mut Session, p: &Value) -> Result { const C: &str = "object.envelope.options"; check_numbers(p, &["fidelity"], C)?; - check_numbers(p, WARP_NUMBERS, C)?; - check_switches(p, &["horizontal"], C)?; + // The warp keys are checked whatever is selected: with nothing or only mesh envelopes, too. + warp_kind(p, C, None)?; let fid = p.get("fidelity").and_then(Value::as_f64); if fid.is_some_and(|f| !(0.0..=100.0).contains(&f)) { return Err(bad(C, "fidelity must be 0..100")); diff --git a/crates/engine/src/cmd/maskedit.rs b/crates/engine/src/cmd/maskedit.rs index 65bf15086..01721471c 100644 --- a/crates/engine/src/cmd/maskedit.rs +++ b/crates/engine/src/cmd/maskedit.rs @@ -14,7 +14,7 @@ use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_doc::{Document, MaskEdit, Node, NodeId, NodeKind, Selection}; -use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, id_param, switch_or}; +use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, object_param, switch_or}; /// Name of the temporary mask-editing layer. pub const MASK_EDIT_LAYER: &str = "Opacity Mask Editing"; @@ -117,7 +117,7 @@ fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.mask_edit.is_some() { return Err(bad(C, "already editing an opacity mask")); } - let id = id_param(p, "id").or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let id = object_param(p, "id", C)?.or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; let layer = start(s, id)?; Ok(json!({ "layer": layer.0 })) } @@ -136,6 +136,7 @@ fn start(s: &mut Session, id: NodeId) -> Result { fn view_mask(s: &mut Session, p: &Value) -> Result { const C: &str = "transparency.viewOpacityMask"; + let given = object_param(p, "id", C)?; let st = s.doc()?; let editing = st.doc.mask_edit.map(|m| m.object); if !switch_or(p, "on", st.shown_mask().is_none(), C)? { @@ -144,7 +145,7 @@ fn view_mask(s: &mut Session, p: &Value) -> Result { st.revision += 1; return Ok(json!({ "on": false, "id": id.map(|i| i.0) })); } - let id = id_param(p, "id").or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; + let id = given.or(editing).or_else(|| first_masked(st)).ok_or_else(|| bad(C, "select an object with an opacity mask"))?; match editing { Some(e) if e != id => return Err(bad(C, "already editing another object's opacity mask")), Some(_) => {} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index 3c16fba87..6992f42ab 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -106,7 +106,7 @@ pub fn specs() -> Vec { "Reset Bounding Box", ["Object", "Transform"], None, - "{} square the bounding box of the selected objects to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset}", + "{} square the bounding box of the selected objects, and of every object inside them (the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks), to the page again after a rotation (their geometry stays; one undo step, none when no box is rotated) → {changed: objects reset, the objects inside included}", has_selection, reset_bounding_box ), @@ -223,21 +223,63 @@ pub(crate) fn squash(s: &mut Session, from: usize, label: &str) { // ---------- Reset Bounding Box ---------- +/// Squares each selected object and every object inside it: the children of groups, compound paths, +/// clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. fn reset_bounding_box(s: &mut Session, _: &Value) -> Result { let ids = selected_roots(s)?; let d = &s.doc()?.doc; - let turned: Vec = ids.into_iter().filter(|id| d.node(*id).is_some_and(|n| n.bbox_angle != 0.0)).collect(); - if !turned.is_empty() { + let mut changed = 0; + let squared: Vec<(NodeId, Node)> = ids + .into_iter() + .filter_map(|id| { + let (n, count) = squared(d.node(id)?); + changed += count; + Some((id, n?)) + }) + .collect(); + if !squared.is_empty() { s.edit("Reset Bounding Box", |d, _| { - for id in &turned { - if let Some(n) = d.node_mut(*id) { - n.bbox_angle = 0.0; + for (id, n) in squared { + if let Some(slot) = d.node_mut(id) { + *slot = n; } } Ok(()) })?; } - Ok(json!({ "changed": turned.len() })) + Ok(json!({ "changed": changed })) +} + +/// A copy of `n` with the angle of every object in its subtree set to 0, or `None` when no box in +/// it stands at an angle, and the number of objects squared. The subtree takes in children and the +/// art of linked opacity masks (an unlinked mask's art turns on its own); layers have no box. Only +/// the nodes on the way to a turned object are copied. +fn squared(n: &Node) -> (Option, usize) { + let mut out = (!n.is_layer() && n.bbox_angle != 0.0).then(|| { + let mut c = n.clone(); + c.bbox_angle = 0.0; + c + }); + let mut count = usize::from(out.is_some()); + for (i, c) in n.children().into_iter().flatten().enumerate() { + let (child, k) = squared(c); + count += k; + if let Some(child) = child + && let Some(slot) = out.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i)) + { + *slot = Arc::new(child); + } + } + if let Some(m) = n.extras().mask.as_ref().filter(|m| m.linked) { + let (art, k) = squared(&m.art); + count += k; + if let Some(art) = art + && let Some(m) = out.get_or_insert_with(|| n.clone()).extras_if_any().and_then(|e| e.mask.as_mut()) + { + m.art = Arc::new(art); + } + } + (out, count) } // ---------- Lock / Hide ---------- diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index bac3540fa..ade05bd95 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -16,7 +16,7 @@ use super::edit::{duplicate_in, outermost, selected_roots, selected_roots_to_tra use super::opacitymask::percent; use super::{ CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, f64_or, f64_req, has_doc, has_multi, has_selection, - has_selection_or_guides, id_param, ids_param, matrix_param, ok, point_param, str_param, targets, + has_selection_or_guides, ids_param, matrix_param, object_param, ok, point_param, str_param, targets, }; use crate::{EngineError, LastTransform}; diff --git a/crates/engine/src/cmd/object/align.rs b/crates/engine/src/cmd/object/align.rs index fe350a144..156b35824 100644 --- a/crates/engine/src/cmd/object/align.rs +++ b/crates/engine/src/cmd/object/align.rs @@ -66,7 +66,7 @@ fn measure(s: &Session, p: &Value, cmd: &str) -> Result { Ok(Measure { preview, point: s.prefs.align_glyph_bounds_point, area: s.prefs.align_glyph_bounds_area }) } -/// Param `key` of the Align commands, Shear, Unlock All, Show All and `text.setFormat`'s lists: one +/// Param `key` of the Align commands, Shear, Unlock All, Show All, a warp's `orientation` and `text.setFormat`'s lists: one /// of `values`, or `None` when it is missing or null. Any other value is an error that lists `values`. pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result> { let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) }; diff --git a/crates/engine/src/cmd/object/arrange.rs b/crates/engine/src/cmd/object/arrange.rs index 2e7d2a8e4..7aef60fca 100644 --- a/crates/engine/src/cmd/object/arrange.rs +++ b/crates/engine/src/cmd/object/arrange.rs @@ -1,5 +1,5 @@ use super::align::choice; -use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots}; +use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, json, object_param, ok, selected_roots}; pub(super) enum Arrange { Front, @@ -293,7 +293,7 @@ pub(super) fn show_all(s: &mut Session, p: &Value) -> Result { } pub(super) fn isolate(s: &mut Session, p: &Value) -> Result { - let id = id_param(p, "id") + let id = object_param(p, "id", "object.isolate")? .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) .ok_or_else(|| bad("object.isolate", "missing id"))?; let guides_locked = s.menu.guides_locked; diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index 390f6ef49..bbb5f35d3 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -128,7 +128,7 @@ pub fn proxy_specs() -> Vec { "Recent Colors", [], None, - "{} → {colors: [{hex, color}] newest first (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}", + "{} → {colors: [{hex, color}] newest first, at most 30, each once, kept across documents for the session (fed by every paint command and the eyedropper), lastColor: {hex, color}, lastGradient: gradient paint}", always, |s, _| { let c = |c: &Color| json!({"hex": c.to_hex(), "color": c}); @@ -152,8 +152,9 @@ pub fn proxy_specs() -> Vec { } impl Session { - /// How many colours the Recent Colors rows keep. - pub const RECENT_MAX: usize = 10; + /// How many colors Recent Colors keeps. The Session holds the list, so it stays the same when + /// another document is opened or made active. + pub const RECENT_MAX: usize = 30; /// Remember an applied paint: a solid colour becomes the last colour and the newest recent /// colour, a gradient the last gradient. During a live preview this waits for the commit. diff --git a/crates/engine/src/cmd/patterncmds.rs b/crates/engine/src/cmd/patterncmds.rs index 8f1e99320..614a37d21 100644 --- a/crates/engine/src/cmd/patterncmds.rs +++ b/crates/engine/src/cmd/patterncmds.rs @@ -377,20 +377,24 @@ pub(crate) fn pattern_done(s: &mut Session, _: &Value) -> Result { if editing(s).is_some() { s.end_text_edit(); } - let name = s.edit("Done Editing Pattern", |d, sel| { - let (pe, art) = leave_edit(d, sel)?; - if let Some(def) = d.pattern_mut(&pe.pattern) { - def.art = art; - if def.size_tile_to_art { - def.fit_tile_to_art(); - } - } - Ok(pe.pattern) - })?; + let name = s.edit("Done Editing Pattern", finish_edit)?; after_edit_mode(s, None)?; Ok(json!({ "name": name })) } +/// Leave pattern editing mode in `d`, storing the editing layer's art as the pattern's tile art +/// (resizing the tile to the art when the pattern does so) → the pattern's name. +pub(crate) fn finish_edit(d: &mut Document, sel: &mut Selection) -> Result { + let (pe, art) = leave_edit(d, sel)?; + if let Some(def) = d.pattern_mut(&pe.pattern) { + def.art = art; + if def.size_tile_to_art { + def.fit_tile_to_art(); + } + } + Ok(pe.pattern) +} + fn pattern_cancel(s: &mut Session, _: &Value) -> Result { s.edit("Cancel Pattern Editing", |d, sel| { let (pe, _) = leave_edit(d, sel)?; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index d78ca2782..0de33a22c 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -47,7 +47,7 @@ use vectorcraft_geom::{Affine, Point, Rect, shapes}; use vectorcraft_pdf::CropTo; use super::fileio::{self, Format, RasterImage}; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, id_param, str_param, unique_name}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, finite_numbers, has_doc, object_param, str_param, unique_name}; use crate::{EngineError, MAX_COORD}; const PLACE: &str = "file.place"; @@ -866,7 +866,7 @@ pub fn read_info(p: &Value) -> Result { pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { const C: &str = "image.info"; let st = s.doc()?; - let id = match (id_param(p, "id"), &st.selection.objects[..]) { + let id = match (object_param(p, "id", C)?, &st.selection.objects[..]) { (Some(id), _) => id, (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index fe6a562bf..07c65952e 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -20,6 +20,12 @@ //! a filter does only what they allow: without `doc.read` it is given no objects, without //! `doc.write` an output that changes the document is refused, and without `state` it neither //! reads nor writes the data it keeps with the document. +//! +//! A plug-in of API level 2 that lists `progress` may import the host's `vc_progress`, +//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a +//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its +//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log +//! under its id. use std::sync::{Arc, Mutex}; @@ -46,7 +52,7 @@ pub fn describe(p: &Plugin) -> Value { "id": m.id, "name": m.name, "version": m.version, "kind": m.kind.name(), "author": m.author, "description": m.description, "params": m.params_doc(), "paramsSchema": serde_json::to_value(m).ok().and_then(|v| v.get("params").cloned()), "size": p.size(), "source": p.source(), - "permissions": permissions::names(&m.permissions()), "hash": p.hash(), + "permissions": permissions::names(&m.permissions()), "hash": p.hash(), "api": m.api, }); match m.kind { Kind::Effect => v["effect"] = json!(effect::effect_id(&m.id)), @@ -505,7 +511,7 @@ pub fn specs() -> Vec { "List Plug-ins", [], None, - "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies), hash (the module's SHA-256)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", + "{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\"|\"importer\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.), format? and extensions? (an exporter's or importer's format id, plugin., for document.export or document.open's importer, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null}", always, list ), diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 94a922400..a702f1580 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -152,6 +152,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("zoomWithMouseWheel", "General", "Options", "Zoom with Mouse Wheel", bool), p!("scrubNumericFields", "General", "Options", "Scrub Numeric Fields by Dragging", bool), p!("trackpadRotateView", "General", "Options", "Trackpad Gesture to Rotate View", bool), + p!("holdToolKeys", "General", "Options", "Hold a Tool Shortcut to Use the Tool Temporarily", bool), // Selection & Anchor Display p!("selectionTolerance", "Selection & Anchor Display", "Selection", "Tolerance", num(1.0, 8.0, "px")), p!("objectSelectionByPathOnly", "Selection & Anchor Display", "Selection", "Object Selection by Path Only", bool), @@ -247,6 +248,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ p!("measurementLabels", "Smart Guides", "Display Options", "Measurement Labels", bool), p!("spacingGuides", "Smart Guides", "Display Options", "Spacing Guides", bool), p!("distanceGuides", "Smart Guides", "Display Options", "Invoke Distance Guides", bool), + p!("snapToLastLocation", "Smart Guides", "Display Options", "Snap to Last Location", bool), p!("snappingTolerance", "Smart Guides", "", "Snapping Tolerance", num(0.0, 40.0, "pt")), p!("snapTangent", "Smart Guides", "Geometric Guides", "Snap to Tangent", bool), p!("snapPerpendicular", "Smart Guides", "Geometric Guides", "Snap to Perpendicular", bool), @@ -422,6 +424,9 @@ pub const PREF_SPECS: &[PrefSpec] = &[ // The Align panel menu's Align to Glyph Bounds: Align and Distribute measure type by its glyphs' ink. p!("alignGlyphBoundsPoint", "Type", "Align to Glyph Bounds", "Point Text", bool), p!("alignGlyphBoundsArea", "Type", "Align to Glyph Bounds", "Area Text", bool), + // Artboard Options › Global: the canvas darkens the area outside the artboards while the Artboard tool is active. + p!("fadeRegionOutsideArtboard", "General", "Artboard Options", "Fade Region Outside Artboard", bool), + p!("artboardUpdateWhileDragging", "General", "Artboard Options", "Update While Dragging", bool), ]; pub fn spec(key: &str) -> Option<&'static PrefSpec> { diff --git a/crates/engine/src/cmd/shaper.rs b/crates/engine/src/cmd/shaper.rs index 511e8ce4f..44ec938bc 100644 --- a/crates/engine/src/cmd/shaper.rs +++ b/crates/engine/src/cmd/shaper.rs @@ -2,7 +2,7 @@ //! art retained underneath. Recipes select topology regions; source edits re-evaluate them in //! the same undo transaction. Rendering and exporters only see ordinary groups and paths. use super::pathops::Beside; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, point_param}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -785,15 +785,15 @@ fn end_isolation(s: &mut Session) -> Result<()> { st.isolation = editing_layer(&st.doc); Ok(()) } -fn target(s: &Session, p: &Value) -> Result { - let id = id_param(p, "id") +fn target(s: &Session, p: &Value, cmd: &str) -> Result { + let id = object_param(p, "id", cmd)? .or_else(|| s.active().and_then(|d| d.selection.objects.first().copied())) - .ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; - s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad("shaper.release", "select a Shaper group"))?; + .ok_or_else(|| bad(cmd, "select a Shaper group"))?; + s.doc()?.doc.node(id).filter(|n| n.extras().shaper.is_some()).ok_or_else(|| bad(cmd, "select a Shaper group"))?; Ok(id) } fn release(s: &mut Session, p: &Value) -> Result { - let id = target(s, p)?; + let id = target(s, p, "shaper.release")?; let ids = s.edit("Release Shaper Group", |d, sel| { let original = d.node(id).map(sources).ok_or(EngineError::NoNode(id))?; let (parent, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; @@ -809,7 +809,7 @@ fn release(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) } fn expand(s: &mut Session, p: &Value) -> Result { - let id = target(s, p)?; + let id = target(s, p, "shaper.expand")?; s.edit("Expand Shaper Group", |d, sel| { let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?; n.extras_mut().shaper = None; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index a15ea9ff4..36780707f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -20,7 +20,7 @@ use vectorcraft_color::Paint; use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind}; use vectorcraft_geom::Rect; -use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, id_param, index, ok, str_param, unique_name}; +use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name}; use crate::EngineError; pub fn specs() -> Vec { @@ -290,7 +290,7 @@ fn style_index(d: &Document, name: &str) -> Result { /// The object a style is made from: `id`, else the first selected object. fn source<'a>(s: &'a Session, p: &Value, cmd: &str) -> Result<(NodeId, &'a Node)> { - let id = match id_param(p, "id") { + let id = match object_param(p, "id", cmd)? { Some(id) => id, None => super::edit::selected_roots(s)?.first().copied().ok_or_else(|| bad(cmd, "select an object (or pass `id`)"))?, }; diff --git a/crates/engine/src/cmd/swatch.rs b/crates/engine/src/cmd/swatch.rs index 7944e59a0..f19861685 100644 --- a/crates/engine/src/cmd/swatch.rs +++ b/crates/engine/src/cmd/swatch.rs @@ -1,16 +1,15 @@ //! The Swatches panel: new, delete and duplicate swatches, colour groups and sorting. +use std::collections::{HashMap, HashSet}; + use serde_json::{Value, json}; -use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent}; +use vectorcraft_color::{Color, Gradient, GradientKind, GradientMethod, GradientStop, Paint, Swatch, SwatchGroup, tint_percent}; use vectorcraft_doc::pattern::pattern_paint; use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors}; use vectorcraft_doc::{Document, NodeId}; use super::paint::paint_from; -use super::{ - CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or, - unique_name, -}; +use super::{CommandSpec, Result, Session, bad, bool_or, check_numbers, check_switches, cmd, color_value, has_doc, index, ok, str_param, switch_or}; use crate::EngineError; pub fn specs() -> Vec { @@ -195,10 +194,11 @@ fn swatch_new(s: &mut Session, p: &Value) -> Result { let requested = name_param(p, "name").filter(|_| paints.len() == 1); let names = s.edit("New Swatch", |d, _| { let mut names = vec![]; + let mut free = d.free_swatch_names(); for paint in paints { let name = match &requested { - Some(n) => d.free_swatch_name(n), - None => d.new_swatch_name(&paint), + Some(n) => free.take(n), + None => free.take_new(&paint), }; // A tint swatch's kind is its base's. let tint = matches!(paint, Paint::Solid { swatch: Some(_), .. }); @@ -271,36 +271,116 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result { /// each). A tint (a linked colour that differs from its swatch's) brings its swatch, and with /// `tints` its own colour too. Links to missing swatches count as unlinked. fn artwork_colors(d: &Document, ids: &[NodeId], tints: bool, swatches: &mut Vec, colors: &mut Vec) { + let by_name = swatches_by_name(d); + let mut seen_swatches: HashSet = swatches.iter().cloned().collect(); + let mut seen_colors: HashSet = colors.iter().filter_map(color_key).collect(); for n in ids.iter().filter_map(|id| d.node(*id)) { node_colors(n, &mut |c, link| { - let sw = link.and_then(|l| d.swatch(l)).filter(|w| w.paint.color().is_some()); + let sw = link.and_then(|l| by_name.get(l).copied()).filter(|w| w.paint.color().is_some()); if let Some(w) = sw - && !swatches.contains(&w.name) + && !seen_swatches.contains(&w.name) { + seen_swatches.insert(w.name.clone()); swatches.push(w.name.clone()); } let own = sw.is_none_or(|w| tints && w.paint.color() != Some(*c)); - if own && !colors.contains(c) { + // A color with a NaN component equals no color, so it is never already listed. + if own && color_key(c).is_none_or(|k| seen_colors.insert(k)) { colors.push(*c); } }); } } -/// Link the solid paints in the subtrees of `ids` whose colour is one of `links`' to that swatch, -/// unless they already link to an existing swatch. Returns the number of paints linked. +/// Every swatch by name, as [`Document::swatch`] finds it: the first swatch of each name, and the +/// built-in Registration swatch when no swatch has its name. +fn swatches_by_name(d: &Document) -> HashMap<&str, &Swatch> { + let mut by_name = HashMap::new(); + for w in d.swatches_iter().chain([vectorcraft_color::swatch::registration()]) { + by_name.entry(w.name.as_str()).or_insert(w); + } + by_name +} + +/// A color's model and the bits of its components, with -0.0 read as 0.0: two keys are equal +/// exactly when the colors are equal (`==`). `None` for a color with a NaN component, which +/// equals no color. +type ColorKey = (u8, [u32; 4]); + +fn color_key(c: &Color) -> Option { + let (model, v) = match *c { + Color::Rgb { r, g, b } => (0, [r, g, b, 0.0]), + Color::Cmyk { c, m, y, k } => (1, [c, m, y, k]), + Color::Gray { k } => (2, [k, 0.0, 0.0, 0.0]), + Color::Lab { l, a, b } => (3, [l, a, b, 0.0]), + }; + Some((model, [float_key(v[0])?, float_key(v[1])?, float_key(v[2])?, float_key(v[3])?])) +} + +/// The bits of `v`, with -0.0 read as 0.0, so keys are equal exactly when the values are; `None` +/// for NaN. +fn float_key(v: f32) -> Option { + if v.is_nan() { + None + } else if v == 0.0 { + Some(0) + } else { + Some(v.to_bits()) + } +} + +/// A gradient stop's offset, color, opacity, midpoint, swatch link, tint and process plates, as +/// keys. +type StopKey<'a> = (u32, ColorKey, u32, u32, Option<&'a str>, u32, Option<[u32; 4]>); + +/// A gradient's kind, method, dither and stops, as keys. +type GradientKey<'a> = (u8, u8, bool, Vec>); + +/// A key that is equal for two gradients exactly when the gradients are equal (`==`); `None` when +/// any of its numbers is NaN, so the gradient equals no gradient. +fn gradient_key(g: &Gradient) -> Option> { + let kind = match g.kind { + GradientKind::Linear => 0, + GradientKind::Radial => 1, + GradientKind::Freeform => 2, + }; + let method = match g.method { + GradientMethod::Classic => 0, + GradientMethod::Perceptual => 1, + }; + Some((kind, method, g.dither, g.stops.iter().map(stop_key).collect::>()?)) +} + +/// A stop's key for [`gradient_key`]; `None` when any of its numbers is NaN. +fn stop_key(s: &GradientStop) -> Option> { + let plates = match s.plates { + Some(p) => Some([float_key(p[0])?, float_key(p[1])?, float_key(p[2])?, float_key(p[3])?]), + None => None, + }; + Some((float_key(s.offset)?, color_key(&s.color)?, float_key(s.opacity)?, float_key(s.midpoint)?, s.swatch.as_deref(), float_key(s.tint)?, plates)) +} + +/// Link the solid paints in the subtrees of `ids` whose color is one of `links`' to that swatch +/// (the first one listed for the color), unless they already link to an existing swatch or to the +/// built-in Registration swatch. Returns the number of paints linked. fn link_colors(d: &mut Document, ids: &[NodeId], links: &[(Color, String)]) -> usize { if links.is_empty() { return 0; } - let live: Vec = d.swatches_iter().map(|w| w.name.clone()).collect(); + let live: HashSet = d.swatches_iter().chain([vectorcraft_color::swatch::registration()]).map(|w| w.name.clone()).collect(); + let mut by_color: HashMap = HashMap::new(); + for (c, n) in links { + if let Some(k) = color_key(c) { + by_color.entry(k).or_insert(n); + } + } d.map_solid_paints_in(ids, &mut |c, link, _| { if link.as_ref().is_some_and(|l| live.contains(l)) { return false; } - match links.iter().find(|(lc, _)| lc == c) { - Some((_, n)) => { - *link = Some(n.clone()); + match color_key(c).and_then(|k| by_color.get(&k)) { + Some(n) => { + *link = Some((*n).clone()); true } None => false, @@ -346,8 +426,11 @@ fn swatch_new_group(s: &mut Session, p: &Value) -> Result { } // The global swatches made from the art's colours, which its unlinked paints link to. let mut links: Vec<(Color, String)> = vec![]; + let mut free = d.free_swatch_names(); + free.insert(&name); + group.swatches.iter().for_each(|sw| free.insert(&sw.name)); for (c, global) in new { - let nm = unique_name(&color_name(c), |n| n == name || d.swatch_name_taken(n) || group.swatches.iter().any(|sw| sw.name == n)); + let nm = free.take(&color_name(c)); if global { links.push((c, nm.clone())); } @@ -798,10 +881,12 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result { let (mut linked_to, mut colors) = (vec![], vec![]); artwork_colors(d, &ids, false, &mut linked_to, &mut colors); // Colours a solid swatch already has are skipped. - colors.retain(|c| !d.swatches_iter().any(|w| w.paint.color() == Some(*c))); + let have: HashSet = d.swatches_iter().filter_map(|w| w.paint.color()).filter_map(|c| color_key(&c)).collect(); + colors.retain(|c| color_key(c).is_none_or(|k| !have.contains(&k))); + let mut free = d.free_swatch_names(); let mut links = vec![]; for c in colors { - let name = d.free_swatch_name(&color_name(c)); + let name = free.take(&color_name(c)); d.swatches.push(Swatch { name: name.clone(), paint: Paint::solid(c), global, spot: false }); links.push((c, name)); } @@ -813,10 +898,11 @@ fn swatch_add_used(s: &mut Session, p: &Value) -> Result { fn swatch_unused(s: &mut Session, _: &Value) -> Result { let d = &s.doc()?.doc; + let by_name = swatches_by_name(d); // Links with their tints, so a tint swatch is used where its tint of its base is. let (mut links, mut colors, mut gradients, mut patterns, mut gradient_links) = (vec![], vec![], vec![], vec![], vec![]); let mut color = |c: &Color, link: Option<&String>, tint: f32| match link { - Some(l) if d.swatch(l).is_some() => links.push((l.clone(), tint)), + Some(l) if by_name.contains_key(l.as_str()) => links.push((l.clone(), tint)), _ => colors.push(*c), }; let mut paint = |p: &Paint| match p { @@ -838,16 +924,22 @@ fn swatch_unused(s: &mut Session, _: &Value) -> Result { } } } + let gradient_links: HashSet<&String> = gradient_links.iter().collect(); + let link_names: HashSet<&str> = links.iter().map(|(l, _)| l.as_str()).collect(); + let link_tints: HashSet<(&str, u32)> = links.iter().filter_map(|(l, t)| Some((l.as_str(), float_key(*t)?))).collect(); + let colors: HashSet = colors.iter().filter_map(color_key).collect(); + let patterns: HashSet<&String> = patterns.iter().collect(); + let gradients: HashSet = gradients.iter().filter_map(gradient_key).collect(); let used = |w: &Swatch| { gradient_links.contains(&w.name) - || links.iter().any(|(l, t)| match w.tint_of() { - Some((base, tint)) => l == base && *t == tint, - None => *l == w.name, - }) + || match w.tint_of() { + Some((base, tint)) => float_key(tint).is_some_and(|t| link_tints.contains(&(base, t))), + None => link_names.contains(w.name.as_str()), + } || match &w.paint { Paint::None => true, - Paint::Solid { color, .. } => colors.contains(color), - Paint::Gradient(g) => gradients.contains(&g.gradient), + Paint::Solid { color, .. } => color_key(color).is_some_and(|k| colors.contains(&k)), + Paint::Gradient(g) => gradient_key(&g.gradient).is_some_and(|k| gradients.contains(&k)), Paint::Pattern { pattern, .. } => patterns.contains(pattern), } }; @@ -1017,8 +1109,11 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren } } } + let mut free = d.free_swatch_names(); + free.insert(&name); + swatches.iter().for_each(|w| free.insert(&w.name)); for c in colors.iter().skip(swatches.len()) { - let nm = unique_name(&color_name(*c), |n| n == name || d.swatch_name_taken(n) || swatches.iter().any(|w| w.name == n)); + let nm = free.take(&color_name(*c)); swatches.push(Swatch { name: nm, paint: Paint::solid(*c), global: false, spot: false }); } let g = &mut d.swatch_groups[gi]; diff --git a/crates/engine/src/cmd/symboledit.rs b/crates/engine/src/cmd/symboledit.rs index 0c365919b..618ef2ee3 100644 --- a/crates/engine/src/cmd/symboledit.rs +++ b/crates/engine/src/cmd/symboledit.rs @@ -20,7 +20,7 @@ use vectorcraft_doc::{Document, Node, NodeId, NodeKind, Selection, SymbolEdit, S use vectorcraft_geom::{Affine, Point}; use super::brushsym::{natural_size, place_xf}; -use super::{EngineError, Result, Session, bad, id_param, number_or, ok, str_param}; +use super::{EngineError, Result, Session, bad, number_or, object_param, ok, str_param}; /// The name of the undo step that enters symbol editing mode. const ENTER: &str = "Edit Symbol"; @@ -65,7 +65,7 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result { if st.doc.pattern_edit.is_some() { return Err(bad(C, "finish editing the pattern first")); } - let given = id_param(p, "id").map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty())); + let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty())); let (name, xf, instance) = match given { Some(ids) => { let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::>>(); diff --git a/crates/engine/src/cmd/textedit.rs b/crates/engine/src/cmd/textedit.rs index fba84acaf..29d1de28f 100644 --- a/crates/engine/src/cmd/textedit.rs +++ b/crates/engine/src/cmd/textedit.rs @@ -1676,7 +1676,7 @@ pub(crate) fn type_ranges( at_caret: impl Fn(&str, usize) -> Option<(usize, usize)>, ) -> Result> { // (text, its range; None: all of it). - let targets: Vec<(NodeId, Option<(usize, usize)>)> = match id_param(p, "id") { + let targets: Vec<(NodeId, Option<(usize, usize)>)> = match super::object_param(p, "id", c)? { // An object that isn't type is refused as `ids` of it is. Some(id) if s.doc()?.doc.node(id).is_some_and(|n| !matches!(n.kind, NodeKind::Text(_))) => { return Err(bad(c, "no text objects selected")); diff --git a/crates/engine/src/cmd/textstyles.rs b/crates/engine/src/cmd/textstyles.rs index c6442c193..17b2f0f0c 100644 --- a/crates/engine/src/cmd/textstyles.rs +++ b/crates/engine/src/cmd/textstyles.rs @@ -10,8 +10,9 @@ use serde::de::DeserializeOwned; use serde_json::{Map, Value, json}; use vectorcraft_doc::{CharStyle, Document, Node, NodeId, NodeKind, ParaStyle, TextStyleDef}; +use super::clipboard::resources::import_text_styles; use super::typecmd::{refresh_bounds, text_targets}; -use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, id_param, index, ok, str_param}; +use super::{CommandSpec, Result, Run, Session, bad, bool_or, has_doc, index, object_param, ok, str_param}; pub const NORMAL_CHAR: &str = "[Normal Character Style]"; pub const NORMAL_PARA: &str = "[Normal Paragraph Style]"; @@ -111,10 +112,21 @@ macro_rules! style_cmds { |s, _| unused(s, $kind), false, ), + spec( + concat!($p, ".load"), + concat!("Load ", $what, " Styles…"), + &["Window", "Type", $menu], + LOAD_PARAMS, + |s, p| load(s, p, &[$kind]), + true, + ), ] }; } +/// The params of `charStyle.load`, `paraStyle.load` and `textStyle.loadAll`. +const LOAD_PARAMS: &str = "{path? | data?: file text | dataBase64?, name?: file name (default: the path's)} copy the styles of this kind (textStyle.loadAll: character and paragraph styles) from a document VectorCraft opens (see document.formats) into this one, after its own, in one undo step, with the swatches and patterns they paint with, the symbols and brushes those patterns use, and the styles of either kind that type in those patterns and symbols is set in, as Paste brings those of pasted type (a global or spot color whose name this document gives another color is this document's). A style that is the same as the style here of its name, or of its name with a number (`Body 2`), isn't added again; any other is added under a free name (`Body 2`). The file's Normal styles aren't loaded, and a style whose attributes don't read or are out of the range charStyle.new takes, or whose name is empty, is skipped → {added: [names here], existing: [names already here], skipped: [names in the file]} (textStyle.loadAll: {charStyles: {…}, paraStyles: {…}}); charStyle.load and paraStyle.load list only the styles of their own kind"; + pub fn specs() -> Vec { let mut v = style_cmds!( Kind::Char, @@ -132,6 +144,7 @@ pub fn specs() -> Vec { true, )); v.extend(style_cmds!(Kind::Para, "paraStyle", "Paragraph Styles", "Paragraph", "")); + v.push(spec("textStyle.loadAll", "Load All Styles…", &["Window", "Type"], LOAD_PARAMS, |s, p| load(s, p, &[Kind::Char, Kind::Para]), true)); v } @@ -426,10 +439,11 @@ fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map Result>> { +/// Paragraph capture always samples the paragraph at `start`. An `id` that isn't an object id is +/// an error under the `verb` command of `kind`. +fn selection_attrs(s: &Session, p: &Value, kind: Kind, verb: &str) -> Result>> { let d = &s.doc()?.doc; - let id = match id_param(p, "id") { + let id = match object_param(p, "id", &kind.cmd(verb))? { Some(id) => Some(id), None => text_targets(s, &json!({}), "").ok().and_then(|v| v.first().copied()), }; @@ -451,14 +465,14 @@ fn selection_attrs(s: &Session, p: &Value, kind: Kind) -> Result Result>> { - selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char }) + selection_attrs(s, &json!({}), if para { Kind::Para } else { Kind::Char }, "new") } /// The attributes `charStyle.new` (`para`: `paraStyle.new`) would give a style made now from /// `target` (its `id`, `start` and `end`; empty for the selected objects). `None` without text /// there. pub fn target_style_attrs(s: &Session, target: &Value, para: bool) -> Option> { - selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }).ok().flatten() + selection_attrs(s, target, if para { Kind::Para } else { Kind::Char }, "new").ok().flatten() } /// The attributes a new character or paragraph style receives when no text is selected. @@ -631,7 +645,7 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result { /// (`charStyle.duplicate`), which `attrs` keeps as [`attrs_param`] keeps the target text's. fn new_from(s: &mut Session, p: &Value, kind: Kind, copied: Option<&Map>) -> Result { let c = kind.cmd("new"); - let target = selection_attrs(s, p, kind)?; + let target = selection_attrs(s, p, kind, "new")?; let attrs = match attrs_param(&s.doc()?.doc, p, kind, "new", copied.or(target.as_ref()))? { Some(a) => a, None => match target { @@ -672,8 +686,9 @@ fn apply(s: &mut Session, p: &Value, kind: Kind) -> Result { let style_name = (name != kind.normal()).then(|| name.clone()); // A Type tool range or whole text objects. let text_range = super::typecmd::TextRange::parse(p, &c)?; - let range = id_param(p, "id").filter(|_| text_range.is_some()); - let ids = match range.or_else(|| id_param(p, "id")) { + let given = object_param(p, "id", &c)?; + let range = given.filter(|_| text_range.is_some()); + let ids = match given { Some(id) => vec![id], None => text_targets(s, p, &c)?, }; @@ -754,7 +769,7 @@ fn redefine_to(s: &mut Session, kind: Kind, name: &str, new_attrs: Map Result { let name = name_param(p, kind, "redefine")?.to_string(); - let attrs = selection_attrs(s, p, kind)?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?; + let attrs = selection_attrs(s, p, kind, "redefine")?.ok_or_else(|| bad(&kind.cmd("redefine"), "select text to redefine the style from"))?; redefine_to(s, kind, &name, attrs, "redefine") } @@ -899,5 +914,89 @@ fn unused(s: &mut Session, kind: Kind) -> Result { Ok(json!({ "names": names })) } +/// The styles of `kind` in `src` that load, with their source names so that art in the file keeps +/// referring to them: not the Normal style, and not one whose attributes don't read, whose sizes, +/// scales, leading, shifts or stroke width are out of the range `charStyle.new` takes, or whose name +/// is empty, which are skipped. → (the styles, the names in `src` of those skipped) +fn loadable(src: &Document, kind: Kind) -> (Vec, Vec) { + const RANGED: [&str; 8] = ["size", "h_scale", "v_scale", "leading", "baseline_shift", "tcyUpDown", "tcyLeftRight", "stroke_width"]; + let (mut defs, mut skipped) = (vec![], vec![]); + for def in kind.defs(src) { + let name = def.name.as_str(); + if name.trim() == kind.normal() { + continue; + } + // Paints, features and language are taken as the file stores them; the ranged values are checked. + let mut kept = def.attrs.clone(); + kept.retain(|k, _| !RANGED.contains(&k.as_str())); + match attrs_param(src, &json!({ "attrs": def.attrs }), kind, "load", Some(&kept)).ok().flatten().filter(|_| !name.trim().is_empty()) { + Some(attrs) => defs.push(TextStyleDef { name: name.to_string(), attrs }), + None => skipped.push(def.name.clone()), + } + } + (defs, skipped) +} + +/// Load Character Styles, Load Paragraph Styles and Load All Styles: the `kinds` of style of the +/// document `p` gives (`path`, `data` or `dataBase64`) added to the active one with the swatches and +/// patterns they paint with, as Paste adds those of pasted type ([`import_text_styles`]). +fn load(s: &mut Session, p: &Value, kinds: &[Kind]) -> Result { + let label = match kinds { + [Kind::Char] => "Load Character Styles", + [Kind::Para] => "Load Paragraph Styles", + _ => "Load All Styles", + }; + let c = &match kinds { + [kind] => kind.cmd("load"), + _ => "textStyle.loadAll".to_string(), + }; + let path = str_param(p, "path"); + let bytes = match (path, str_param(p, "data"), str_param(p, "dataBase64")) { + (Some(path), ..) => super::fileio::read_file(path)?, + (None, Some(text), _) => text.as_bytes().to_vec(), + (None, None, Some(b64)) => vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(c, "bad dataBase64"))?, + _ => return Err(bad(c, "give `path`, `data` or `dataBase64`")), + }; + let file = str_param(p, "name").or(path).unwrap_or("Styles"); + let src = super::fileio::load(file, &bytes).map_err(|e| bad(c, e.to_string()))?; + if src.preview_only { + return Err(bad(c, "only this Affinity file's embedded preview could be read; use File › Open to see it with its warning")); + } + // The source with only the styles that load. Those of a kind not loaded still come along when + // a pattern or symbol that a loaded style paints with sets type in them. + let mut src = src.doc; + let mut skipped = [vec![], vec![]]; + for kind in [Kind::Char, Kind::Para] { + let (defs, names) = loadable(&src, kind); + *kind.defs_mut(&mut src) = defs; + if kinds.contains(&kind) { + skipped[usize::from(kind == Kind::Para)] = names; + } + } + let (chars, paras) = (kinds.contains(&Kind::Char), kinds.contains(&Kind::Para)); + let mut trial = (*s.doc()?.doc).clone(); + let mut placed = import_text_styles(&src, &mut trial, chars, paras); + // An undo step only when the document changes. The step takes the document the import made, + // unless the edit first ends a typing session, which can change the document it starts from. + if trial != *s.doc()?.doc { + if super::typing_in_progress(s) { + placed = s.edit(label, |d, _| Ok(import_text_styles(&src, d, chars, paras)))?; + } else { + s.edit(label, |d, _| { + *d = trial; + Ok(()) + })?; + } + } + let reply = |para: bool| { + let names = |added: bool| placed.iter().filter(|p| p.para == para && p.added == added).map(|p| p.name.as_str()).collect::>(); + json!({ "added": names(true), "existing": names(false), "skipped": skipped[usize::from(para)] }) + }; + Ok(match kinds { + [kind] => reply(*kind == Kind::Para), + _ => json!({ "charStyles": reply(false), "paraStyles": reply(true) }), + }) +} + #[cfg(test)] mod tests; diff --git a/crates/engine/src/cmd/textstyles/tests.rs b/crates/engine/src/cmd/textstyles/tests.rs index 7c0fd9963..73c938dc1 100644 --- a/crates/engine/src/cmd/textstyles/tests.rs +++ b/crates/engine/src/cmd/textstyles/tests.rs @@ -733,6 +733,480 @@ fn several_styles_are_deleted_in_one_step() { assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style"); } +/// A saved document whose character and paragraph styles are `char` and `para` (name, attrs), +/// stored as they are, as `charStyle.load`'s `dataBase64`. +fn styles_file(char: &[(&str, Value)], para: &[(&str, Value)]) -> String { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let mut d = (*s.doc().unwrap().doc).clone(); + let defs = |v: &[(&str, Value)]| { + v.iter().map(|(n, a)| TextStyleDef { name: n.to_string(), attrs: a.as_object().cloned().unwrap_or_default() }).collect::>() + }; + d.char_styles = defs(char).into(); + d.para_styles = defs(para).into(); + vectorcraft_format::base64_encode(&vectorcraft_format::save(&d, false)) +} + +/// Load Character Styles and Load Paragraph Styles copy the other document's styles of their kind +/// after the document's own, in one undo step. A style with the same attributes as the style here +/// of its name, or of its name with a number, is not added again; any other gets the first free of +/// those names, and the style here and the type using it keep their attributes. The file's Normal +/// style is not loaded, and a style whose attributes do not read, or whose name is empty, is +/// skipped. +#[test] +fn styles_load_from_another_document_in_one_step() { + for p in ["charStyle", "paraStyle"] { + let (mut s, id) = session_with_text("Title"); + let normal = if p == "charStyle" { NORMAL_CHAR } else { NORMAL_PARA }; + let key = if p == "charStyle" { "size" } else { "space_after" }; + let size = |v: f64| Value::Object(Map::from_iter([(key.to_string(), json!(v))])); + for (n, v) in [("Body", 12.0), ("Accent", 9.0), ("Accent 2", 1.0)] { + s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": size(v)})).unwrap(); + } + s.execute(&format!("{p}.apply"), &json!({"name": "Accent", "id": id})).unwrap(); + let file = + [("Body", size(12.0)), ("Accent", size(20.0)), ("Fresh", size(5.0)), (normal, size(30.0)), ("Bad", json!({"nope": 1})), (" ", size(2.0))]; + let data = styles_file(&file, &file); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r, json!({"added": ["Accent 3", "Fresh"], "existing": ["Body"], "skipped": ["Bad", " "]}), "{p}"); + assert_eq!(listed(&mut s, p), [normal, "Body", "Accent", "Accent 2", "Accent 3", "Fresh"], "{p}"); + let d = s.doc().unwrap().doc.clone(); + let attrs = |name: &str| super::style_attrs(&d, p == "paraStyle", name).map(Value::Object); + assert_eq!((attrs("Accent"), attrs("Accent 3"), attrs(normal)), (Some(size(9.0)), Some(size(20.0)), Some(json!({}))), "{p}"); + // The other kind and the type are as they were. + let (other, mine) = if p == "charStyle" { (&d.para_styles, &d.char_styles) } else { (&d.char_styles, &d.para_styles) }; + let (other_before, mine_before) = + if p == "charStyle" { (&before.para_styles, &before.char_styles) } else { (&before.char_styles, &before.para_styles) }; + assert_eq!((other, &mine[..mine_before.len()]), (other_before, &mine_before[..]), "{p}"); + assert_eq!(d.node(NodeId(id)), before.node(NodeId(id)), "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + // Loading the same file again adds nothing and makes no undo step. + let again = s.execute(&format!("{p}.load"), &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(again["added"], json!([]), "{p}"); + assert_eq!(again["existing"], json!(["Body", "Accent 3", "Fresh"]), "{p}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}"); + let undone = s.execute("edit.undo", &json!({})).unwrap()["undone"].clone(); + assert_eq!(undone, if p == "charStyle" { "Load Character Styles" } else { "Load Paragraph Styles" }); + assert_eq!(s.doc().unwrap().doc, before, "{p}"); + } +} + +/// Load All Styles loads both kinds in one undo step, from a path too; a file that does not open, +/// or no file, loads nothing. +#[test] +fn load_all_styles_loads_both_kinds_in_one_step() { + let (mut s, _) = session_with_text("Title"); + let data = styles_file(&[("Lead", json!({"size": 30.0}))], &[("Centered", json!({"justify": "Center"}))]); + let dir = std::env::temp_dir().join(format!("vc-load-styles-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Other.vectorcraft"); + std::fs::write(&path, vectorcraft_format::base64_decode(&data).unwrap()).unwrap(); + let before = s.doc().unwrap().doc.clone(); + let undo = s.doc().unwrap().history.undo.len(); + for bad in [json!({}), json!({"data": "not a document", "name": "x.vectorcraft"}), json!({"dataBase64": "!"})] { + assert!(s.execute("textStyle.loadAll", &bad).is_err(), "{bad}"); + } + let r = s.execute("textStyle.loadAll", &json!({"path": path.to_string_lossy()})).unwrap(); + std::fs::remove_dir_all(&dir).ok(); + let one = |name: &str| json!({"added": [name], "existing": [], "skipped": []}); + assert_eq!(r, json!({"charStyles": one("Lead"), "paraStyles": one("Centered")})); + assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Lead"]); + assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered"]); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Load All Styles"); + assert_eq!(s.doc().unwrap().doc, before); +} + +/// A loaded character style whose size, scale, leading, shift or stroke width is out of the range +/// `charStyle.new` takes is skipped. +#[test] +fn loaded_styles_out_of_range_are_skipped() { + let (mut s, _) = session_with_text("Title"); + let file = [ + ("Huge", json!({"size": 2000.0})), + ("Flat", json!({"h_scale": 0.5})), + ("Shifted", json!({"baseline_shift": 5000.0})), + ("Thick", json!({"stroke_width": -2.0})), + ("Fine", json!({"size": 12.0, "h_scale": 50.0})), + ]; + let data = styles_file(&file, &[]); + let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r, json!({"added": ["Fine"], "existing": [], "skipped": ["Huge", "Flat", "Shifted", "Thick"]})); + assert_eq!(listed(&mut s, "charStyle"), [NORMAL_CHAR, "Fine"]); +} + +/// A loaded style brings the pattern it paints with, renamed when the document has another pattern +/// of that name, as Paste brings it; a global color whose name the document gives another color +/// is the document's. +#[test] +fn loaded_styles_bring_the_patterns_and_swatches_they_paint_with() { + // A pattern "Dots" made from a `size` square, a global "Ink", and type painted with each. + let painted = |size: f64, ink: &str| { + let (mut s, id) = session_with_text("Dots"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": size, "height": size})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + s.execute("swatch.new", &json!({"name": "Ink", "color": ink, "global": true})).unwrap(); + (s, id) + }; + let (mut src, id) = painted(10.0, "#3366cc"); + src.execute("select.set", &json!({"ids": [id]})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Ink", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Inked", "id": id})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, id) = painted(30.0, "#ff0000"); + let r = s.execute("charStyle.load", &json!({"dataBase64": data, "name": "Other.vectorcraft"})).unwrap(); + assert_eq!(r["added"], json!(["Patterned", "Inked"])); + let d = s.doc().unwrap().doc.clone(); + let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string(); + assert!(d.pattern("Dots 2").is_some() && fill("Patterned").contains("\"Dots 2\""), "{}", fill("Patterned")); + assert!(fill("Inked").contains("\"Ink\"") && d.swatch("Ink 2").is_none(), "{}", fill("Inked")); + s.execute("charStyle.apply", &json!({"name": "Patterned", "id": id})).unwrap(); + assert!(matches!(&runs(&s, id)[0].style.fill, vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2")); +} + +/// A pattern whose type is set in a loaded style that was renamed on the way in ("Accent 2") is +/// the same pattern when the file is loaded again, and so is the style that paints with it, also +/// when "Accent" itself paints with another pattern. +#[test] +fn reloaded_patterns_follow_the_renamed_styles_their_type_uses() { + for patterned in [false, true] { + let (mut src, id) = session_with_text("Tile"); + if patterned { + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + } + src.execute("text.setRangeStyle", &json!({"id": id, "start": 0, "end": 4, "kerning": 40})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + if patterned { + // A solid fill over the style's, so the tile's type itself paints with no pattern. + src.execute("paint.setFill", &json!({"color": "#000000", "ids": [id]})).unwrap(); + } + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + s.execute("charStyle.load", &load).unwrap(); + let counts = |s: &Session| { + let d = &s.doc().unwrap().doc; + (d.patterns.len(), d.char_styles.iter().map(|c| c.name.clone()).collect::>()) + }; + let first = counts(&s); + assert!(first.1.contains(&"Accent 2".to_string()) && first.1.contains(&"Tiled".to_string()), "{patterned}: {first:?}"); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{patterned}: {r}"); + assert_eq!(counts(&s), first, "{patterned}"); + } +} + +/// A loaded style that paints with a pattern whose type is set in that same style still paints +/// with the pattern when the pattern comes in under a free name ("Dots 2"). The commands refuse to +/// make that loop, so the source is a document edited directly, as a file may hold it. +#[test] +fn a_style_and_the_pattern_that_uses_it_load_together() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone(); + source.char_styles.retain(|s| s.name != "Painted"); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted; + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let fill = |s: &Session, name: &str| super::style_attrs(&s.doc().unwrap().doc, false, name).unwrap()["fill"].to_string(); + let source_fill = super::style_attrs(&source, false, "Accent").unwrap()["fill"].to_string(); + assert!(source_fill.contains("\"Dots\""), "{source_fill}"); + let (mut s, _) = session_with_text("X"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + s.execute("charStyle.load", &load).unwrap(); + assert!(s.doc().unwrap().doc.pattern("Dots 2").is_some()); + assert!(fill(&s, "Accent").contains("\"Dots 2\""), "{}", fill(&s, "Accent")); + // Loaded again, the two are found under those names, and nothing is added. + let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), (doc, undo)); +} + +/// A style and the pattern it paints with, whose type is set in that style, are both added when +/// the document has a style of that name and attributes but another pattern of that name: the +/// copy of the style paints with the copy of the pattern, and a second load adds nothing. A loop the +/// document has is used under the names it has together. +#[test] +fn a_style_in_a_loop_is_not_matched_to_a_style_that_paints_with_another_pattern() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": other})).unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Painted", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + let painted = source.char_styles.iter().find(|s| s.name == "Painted").unwrap().attrs.clone(); + source.char_styles.retain(|s| s.name != "Painted"); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().attrs = painted.clone(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let (mut s, _) = session_with_text("X"); + let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + s.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + doc.char_styles.push(TextStyleDef { name: "Accent".into(), attrs: painted }); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!(["Accent 2"]), "{r}"); + let d = s.doc().unwrap().doc.clone(); + let fill = |name: &str| super::style_attrs(&d, false, name).unwrap()["fill"].to_string(); + assert!(fill("Accent").contains("\"Dots\"") && fill("Accent 2").contains("\"Dots 2\""), "{} {}", fill("Accent"), fill("Accent 2")); + let NodeKind::Text(text) = &d.pattern("Dots 2").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.runs[0].style.style_name.as_deref(), Some("Accent 2")); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); + // With the loop's style renamed "Accent 3" and an "Accent 2" listed before it that also paints + // with "Dots 2", the loop is still found as "Accent 3" and "Dots 2", the names it has together. + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let at = doc.char_styles.iter().position(|c| c.name == "Accent 2").unwrap(); + doc.char_styles[at].name = "Accent 3".into(); + let copy = TextStyleDef { name: "Accent 2".into(), attrs: doc.char_styles[at].attrs.clone() }; + doc.char_styles.insert(at, copy); + let tile = doc.patterns.iter_mut().find(|p| p.name == "Dots 2").unwrap(); + let NodeKind::Text(text) = &mut std::sync::Arc::make_mut(&mut tile.art[0]).kind else { panic!("pattern art should be type") }; + text.runs[0].style.style_name = Some("Accent 3".into()); + let d = s.doc().unwrap().doc.clone(); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r, json!({"added": [], "existing": ["Accent 3"], "skipped": []})); + assert_eq!(s.doc().unwrap().doc, d); +} + +/// A brush in a loaded style's pattern whose art sets type in a style renamed on the way in +/// ("Accent 2") is the same brush when the file is loaded again. +#[test] +fn reloaded_brushes_follow_the_renamed_styles_their_art_uses() { + let (mut src, id) = session_with_text("Ink"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("brush.new", &json!({"type": "art", "name": "Lettering", "ids": [id]})).unwrap(); + let path = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 40, "height": 10})).unwrap()["id"].clone(); + src.execute("brush.apply", &json!({"name": "Lettering", "ids": [path]})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [path]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 10}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert!(r["added"].as_array().unwrap().contains(&json!("Accent 2")), "{r}"); + let d = s.doc().unwrap().doc.clone(); + let lib = vectorcraft_brush::library(&d); + let brush = lib.iter().find(|b| b.name == "Lettering").unwrap(); + let mut styles = vec![]; + for a in brush.art() { + a.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + styles.extend(t.runs.iter().filter_map(|r| r.style.style_name.clone())); + } + }); + } + assert_eq!(styles, ["Accent 2"]); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); +} + +/// A style name from a saved document stays the name used by type in imported pattern art. +#[test] +fn loaded_style_keeps_the_name_used_by_pattern_type() { + let (mut src, id) = session_with_text("Tile"); + src.execute("charStyle.new", &json!({"name": "Accent", "attrs": {"kerning": 40}})).unwrap(); + src.execute("charStyle.apply", &json!({"name": "Accent", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + source.char_styles.iter_mut().find(|s| s.name == "Accent").unwrap().name = " Accent ".into(); + let tile = source.patterns.iter_mut().find(|p| p.name == "Tiles").unwrap(); + let art = std::sync::Arc::make_mut(&mut tile.art[0]); + let NodeKind::Text(text) = &mut art.kind else { panic!("pattern art should be type") }; + text.runs[0].style.style_name = Some(" Accent ".into()); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&source, false)); + let (mut dst, _) = session_with_text("X"); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + dst.execute("charStyle.load", &load).unwrap(); + let doc = &dst.doc().unwrap().doc; + assert!(doc.char_styles.iter().any(|s| s.name == " Accent ")); + let NodeKind::Text(text) = &doc.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.runs[0].style.style_name.as_deref(), Some(" Accent ")); +} + +/// Load Character Styles brings the paragraph style that type in a loaded style's pattern is set +/// in, renamed when the document has another style of that name, without listing it in the reply. +#[test] +fn loaded_pattern_type_keeps_its_paragraph_style() { + let (mut src, id) = session_with_text("Tile"); + src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap(); + src.execute("paraStyle.apply", &json!({"name": "Centered", "id": id})).unwrap(); + src.execute("object.pattern.make", &json!({"name": "Tiles", "edit": false, "ids": [id]})).unwrap(); + let other = src.execute("text.create", &json!({"x": 10, "y": 90, "text": "B"})).unwrap()["id"].as_u64().unwrap(); + src.execute("paint.setFill", &json!({"swatch": "Tiles", "ids": [other]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Tiled", "id": other})).unwrap(); + let data = vectorcraft_format::base64_encode(&vectorcraft_format::save(&src.doc().unwrap().doc, false)); + let (mut s, _) = session_with_text("X"); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Right"}})).unwrap(); + let load = json!({"dataBase64": data, "name": "Other.vectorcraft"}); + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r, json!({"added": ["Tiled"], "existing": [], "skipped": []})); + let d = s.doc().unwrap().doc.clone(); + assert_eq!(listed(&mut s, "paraStyle"), [NORMAL_PARA, "Centered", "Centered 2"]); + assert_eq!(super::style_attrs(&d, true, "Centered 2").map(Value::Object), Some(json!({"justify": "Center"}))); + let NodeKind::Text(text) = &d.pattern("Tiles").unwrap().art[0].kind else { panic!("pattern art should be type") }; + assert_eq!(text.para_styles().filter_map(|p| p.style_name.as_deref()).collect::>(), ["Centered 2"]); + // Loaded again, nothing is added. + let r = s.execute("charStyle.load", &load).unwrap(); + assert_eq!(r["added"], json!([]), "{r}"); + assert_eq!(s.doc().unwrap().doc, d); +} + +/// A pattern whose tile paints with itself is found under its renamed copy on a second load. +#[test] +fn self_referencing_pattern_is_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [square]})).unwrap(); + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [tile]})).unwrap(); + let art = src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone(); + assert!(art.appearance.items.iter().any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots"))); + let mut source = (*src.doc().unwrap().doc).clone(); + source.patterns.iter_mut().find(|p| p.name == "Dots").unwrap().art = vec![std::sync::Arc::new(art)]; + src.execute("paint.setFill", &json!({"swatch": "Dots", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + source.char_styles = src.doc().unwrap().doc.char_styles.clone(); + assert!(super::style_attrs(&source, false, "Patterned").unwrap()["fill"].to_string().contains("\"Dots\"")); + let (mut dst, _) = session_with_text("X"); + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": "Dots", "edit": false, "ids": [different]})).unwrap(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(dst.doc().unwrap().doc.pattern("Dots 2").is_some()); + assert!( + dst.doc().unwrap().doc.pattern("Dots 2").unwrap().art[0] + .appearance + .items + .iter() + .any(|item| matches!(item.paint(), vectorcraft_color::Paint::Pattern { pattern, .. } if pattern == "Dots 2")) + ); + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc, before); +} + +/// Patterns whose tiles each paint with themselves are matched one loop at a time: nine of them, +/// each in the document under its name with other art and as the copy an earlier load added, +/// are found under those copies, and a second load adds nothing. +#[test] +fn separate_self_referencing_patterns_are_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + let mut tiles = vec![]; + for k in 0..9 { + let name = format!("Dots{k}"); + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": name, "edit": false, "ids": [square]})).unwrap(); + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": name, "ids": [tile]})).unwrap(); + tiles.push((name.clone(), src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone())); + src.execute("paint.setFill", &json!({"swatch": name, "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": format!("Patterned{k}"), "id": id})).unwrap(); + } + let mut source = (*src.doc().unwrap().doc).clone(); + for (name, art) in tiles { + source.patterns.iter_mut().find(|p| p.name == name).unwrap().art = vec![std::sync::Arc::new(art)]; + } + let (mut dst, _) = session_with_text("X"); + for k in 0..9 { + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); + } + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); + for k in 0..9 { + assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); + } + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); + assert_eq!(dst.doc().unwrap().doc, before); + // With one loop's pattern gone from the document, the others are still found. + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + target.patterns.retain(|p| !p.name.starts_with("Dots8")); + let left = target.patterns.len(); + let third = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), left + 1); + assert!(third.iter().filter(|s| s.name != "Patterned8" && !s.name.starts_with("Patterned8 ")).all(|s| !s.added)); +} + +/// Nine patterns whose tiles each paint with the next one, the last with the first, in the +/// document under their names with other art and as the copies an earlier load added, are found +/// under those copies, and a second load adds nothing. +#[test] +fn a_long_loop_of_patterns_is_not_added_again() { + let (mut src, id) = session_with_text("Patterned"); + for k in 0..9 { + let square = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [square]})).unwrap(); + } + let mut tiles = vec![]; + for k in 0..9 { + let tile = src.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 10, "height": 10})).unwrap()["id"].clone(); + src.execute("paint.setFill", &json!({"swatch": format!("Dots{}", (k + 1) % 9), "ids": [tile]})).unwrap(); + tiles.push(src.doc().unwrap().doc.node(NodeId(tile.as_u64().unwrap())).unwrap().clone()); + } + src.execute("paint.setFill", &json!({"swatch": "Dots0", "ids": [id]})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Patterned", "id": id})).unwrap(); + let mut source = (*src.doc().unwrap().doc).clone(); + for (k, art) in tiles.into_iter().enumerate() { + source.patterns.iter_mut().find(|p| p.name == format!("Dots{k}")).unwrap().art = vec![std::sync::Arc::new(art)]; + } + let (mut dst, _) = session_with_text("X"); + for k in 0..9 { + let different = dst.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 30, "height": 30})).unwrap()["id"].clone(); + dst.execute("object.pattern.make", &json!({"name": format!("Dots{k}"), "edit": false, "ids": [different]})).unwrap(); + } + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + super::super::clipboard::resources::import_text_styles(&source, target, true, true); + for k in 0..9 { + assert!(dst.doc().unwrap().doc.pattern(&format!("Dots{k} 2")).is_some(), "{k}"); + } + let before = dst.doc().unwrap().doc.clone(); + let target = std::sync::Arc::make_mut(&mut dst.doc_mut().unwrap().doc); + let again = super::super::clipboard::resources::import_text_styles(&source, target, true, true); + assert!(again.iter().all(|s| !s.added)); + assert_eq!(dst.doc().unwrap().doc.patterns.len(), before.patterns.len()); + assert_eq!(dst.doc().unwrap().doc, before); +} + /// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no /// mojikumi) are still attributes a style can set, and a style captured with one applies to /// text at the default, is duplicated and comes back out of a library. diff --git a/crates/engine/src/cmd/typemenu.rs b/crates/engine/src/cmd/typemenu.rs index 6d18bb5eb..83dfd925d 100644 --- a/crates/engine/src/cmd/typemenu.rs +++ b/crates/engine/src/cmd/typemenu.rs @@ -6,9 +6,11 @@ use serde_json::{Value, json}; use vectorcraft_doc::{CharStyle, Document, Justify, LockedArtboards, Node, NodeId, NodeKind, Quotes, TextKind, TextObject, TextRun}; use vectorcraft_geom::{Affine, Rect, shapes}; +use super::object::choice; use super::typecmd::refresh_bounds; use super::{ - CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, find_command, has_clipboard, has_doc, has_selection, ids_param, str_param, targets, + CommandSpec, EngineError, Result, Session, bad, checked_ids_param, cmd, find_command, has_clipboard, has_doc, has_selection, number_or, + switch_or, targets, text_param, }; pub fn specs() -> Vec { @@ -182,7 +184,7 @@ struct CaseState { fn change_case(s: &mut Session, p: &Value) -> Result { const C: &str = "type.changeCase"; - let case = str_param(p, "case").ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase(); + let case = text_param(p, "case", C)?.ok_or_else(|| bad(C, "missing case"))?.to_ascii_lowercase(); if !["upper", "uppercase", "lower", "lowercase", "title", "sentence"].contains(&case.as_str()) { return Err(bad(C, "case must be upper|lower|title|sentence")); } @@ -253,11 +255,14 @@ pub(crate) fn smarten(text: &str, prev: &mut Option, quotes: Option<&Quote } fn smart_punctuation(s: &mut Session, p: &Value) -> Result { - let (d, e) = (bool_or(p, "dashes", true), bool_or(p, "ellipsis", true)); + const C: &str = "type.smartPunctuation"; + let (d, e) = (switch_or(p, "dashes", true, C)?, switch_or(p, "ellipsis", true, C)?); + let quotes = switch_or(p, "quotes", true, C)?; + let document = choice(p, C, "scope", &["selection", "document"])? == Some("document"); let st = s.doc()?; // Quotes in the document's style (Document Setup → Type). - let q = bool_or(p, "quotes", true).then_some(st.doc.setup.quotes); - let ids = if str_param(p, "scope") == Some("document") || st.selection.is_empty() { + let q = quotes.then_some(st.doc.setup.quotes); + let ids = if document || st.selection.is_empty() { let mut v = vec![]; st.doc.walk(|n| { if matches!(n.kind, NodeKind::Text(_)) { @@ -436,16 +441,19 @@ fn path_options(s: &mut Session, p: &Value) -> Result { let start = fraction("start")?; // `end: null` puts the end bracket back at the end of the path. let end = p.get("end").map(|_| fraction("end")).transpose()?; - let flip = bool_or(p, "flip", false); - let effect = match str_param(p, "effect") { + let flip = switch_or(p, "flip", false, C)?; + let effect = match text_param(p, "effect", C)? { Some(e) => Some(vectorcraft_doc::PathEffect::parse(e).ok_or_else(|| bad(C, format!("unknown effect `{e}`")))?), None => None, }; - let align = match str_param(p, "alignToPath") { + let align = match text_param(p, "alignToPath", C)? { Some(a) => Some(vectorcraft_doc::PathAlign::parse(a).ok_or_else(|| bad(C, format!("unknown alignToPath `{a}`")))?), None => None, }; - let spacing = p.get("spacing").and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(-1000.0, 1000.0)); + let spacing = match p.get("spacing") { + None | Some(Value::Null) => None, + Some(_) => Some(number_or(p, "spacing", 0.0, C)?.clamp(-1000.0, 1000.0)), + }; // Without a flip, when every object already has every value given, the document and its undo // history stay as they are. let has_values = |t: &TextObject| match &t.kind { @@ -608,16 +616,21 @@ fn unicode_char(u: &str) -> Option { fn insert_char(s: &mut Session, p: &Value) -> Result { const C: &str = "type.insert"; - let text = match (str_param(p, "char"), str_param(p, "text")) { - (Some(c), _) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(), - (None, Some(t)) => t.to_string(), - _ => match (p.get("code"), str_param(p, "unicode")) { - (Some(code), _) => alt_code_char(code.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?) - .ok_or_else(|| bad(C, format!("no character has code {code}")))? - .to_string(), - (None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(), - _ => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")), - }, + // Every source is type-checked before one is chosen, so a bad value is refused even when a + // source ahead of it is given. + let ch = text_param(p, "char", C)?; + let txt = text_param(p, "text", C)?; + let code = match p.get("code").filter(|v| !v.is_null()) { + Some(v) => Some(v.as_u64().ok_or_else(|| bad(C, "`code` must be a whole number"))?), + None => None, + }; + let uni = text_param(p, "unicode", C)?; + let text = match (ch, txt, code, uni) { + (Some(c), ..) => special_char(c).ok_or_else(|| bad(C, format!("unknown character `{c}`")))?.to_string(), + (None, Some(t), ..) => t.to_string(), + (None, None, Some(code), _) => alt_code_char(code).ok_or_else(|| bad(C, format!("no character has code {code}")))?.to_string(), + (None, None, None, Some(u)) => unicode_char(u).ok_or_else(|| bad(C, format!("`{u}` is no Unicode character")))?.to_string(), + (None, None, None, None) => return Err(bad(C, "give `char`, `text`, `code` or `unicode`")), }; let ids = texts(s, p, C)?; // When `text` is empty and every object ends with a run of text, the document and its undo @@ -722,11 +735,11 @@ fn all_texts(d: &Document) -> Vec { fn find_replace(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.findReplace"; - let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); - let rep = str_param(p, "replace").unwrap_or("").to_string(); - let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false)); - let ids = match ids_param(p, "ids") { - Some(v) => text_ids(&s.doc()?.doc, &v), + let find = text_param(p, "find", C)?.filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); + let rep = text_param(p, "replace", C)?.unwrap_or("").to_string(); + let (mc, ww) = (switch_or(p, "matchCase", false, C)?, switch_or(p, "wholeWord", false, C)?); + let ids = match p.get("ids").filter(|v| !v.is_null()) { + Some(_) => text_ids(&s.doc()?.doc, &checked_ids_param(s, p, "ids", C)?), None => all_texts(&s.doc()?.doc), }; let st = s.doc()?; @@ -779,8 +792,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { fn find_next(s: &mut Session, p: &Value) -> Result { const C: &str = "edit.findNext"; - let find = str_param(p, "find").filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); - let (mc, ww) = (bool_or(p, "matchCase", false), bool_or(p, "wholeWord", false)); + let find = text_param(p, "find", C)?.filter(|f| !f.is_empty()).ok_or_else(|| bad(C, "missing `find`"))?.to_string(); + let (mc, ww) = (switch_or(p, "matchCase", false, C)?, switch_or(p, "wholeWord", false, C)?); let st = s.doc()?; let all = all_texts(&st.doc); let cur = st.selection.objects.first().and_then(|id| all.iter().position(|x| x == id)); diff --git a/crates/engine/src/command_sets.rs b/crates/engine/src/command_sets.rs index 4752b0a96..a115498e1 100644 --- a/crates/engine/src/command_sets.rs +++ b/crates/engine/src/command_sets.rs @@ -27,8 +27,9 @@ pub const FIT_ACTIVE_ARTBOARD: [&str; 2] = ["artboard.fitToArt", "artboard.fitTo /// [`ON_ACTIVE_ARTBOARD`] and [`FIT_ACTIVE_ARTBOARD`] commands and to a batch; the artboards the /// Artboard tool had selected for its commands; the view's center the app passes to Edit Symbol); /// an action leaves them out ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 21] = [ +const REPLAY_ONLY: [(&str, &str); 22] = [ ("file.new", "created"), + ("edit.newDocumentFromState", "created"), ("document.save", "modified"), ("file.saveAs", "modified"), ("edit.pasteInPlace", "artboard"), diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index cb84e108e..86c46fcf2 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -155,6 +155,17 @@ impl History { self.undo.iter().chain(&self.redo).map(|e| e.bytes).sum() } + /// The document at row `row` of the History panel, `now` being the document now: the rows before + /// it are the undo steps, oldest first, and the rows after it the redo steps, nearest first. + pub(crate) fn state<'a>(&'a self, now: &'a Arc, row: usize) -> Option<&'a Arc> { + let current = self.undo.len(); + match row.cmp(¤t) { + std::cmp::Ordering::Less => self.undo.get(row).map(|e| &e.doc), + std::cmp::Ordering::Equal => Some(now), + std::cmp::Ordering::Greater => self.redo.len().checked_sub(row - current).and_then(|i| self.redo.get(i)).map(|e| &e.doc), + } + } + /// Let the oldest steps go while over the step limit or the memory budget: undo steps first /// (the oldest), then redo steps (the furthest ahead). pub fn trim(&mut self) { diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index ce7c5e4a6..82946ac29 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -41,6 +41,12 @@ pub struct Prefs { /// Align panel menu › Align to Glyph Bounds › Area Text: the same for area type, instead of /// its frame. pub align_glyph_bounds_area: bool, + /// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active, the + /// canvas draws everything outside the artboards darker, the pasteboard and the art on it. + pub fade_region_outside_artboard: bool, + /// Artboard Options › Update While Dragging: the area outside the artboards stays darker while + /// the Artboard tool moves or resizes an artboard; off, it draws as usual until the drag ends. + pub artboard_update_while_dragging: bool, pub display_print_size: bool, pub double_click_to_isolate: bool, pub transform_pattern_tiles: bool, @@ -52,6 +58,9 @@ pub struct Prefs { pub scrub_numeric_fields: bool, /// A two-finger rotation on a Mac trackpad turns the view about the pointer once it reaches 5°. pub trackpad_rotate_view: bool, + /// A single-key tool shortcut held down chooses its tool until the key is let go, and then the + /// tool active before comes back; a quick press chooses the tool for good. + pub hold_tool_keys: bool, /// Offset for Paste / duplicate (Illustrator pastes to the view centre; we offset by this). pub paste_offset: f64, // Selection & Anchor Display @@ -115,6 +124,7 @@ pub struct Prefs { pub measurement_labels: bool, pub spacing_guides: bool, pub distance_guides: bool, + pub snap_to_last_location: bool, pub snapping_tolerance: f64, pub snap_tangent: bool, pub snap_perpendicular: bool, @@ -349,6 +359,8 @@ impl Default for Prefs { use_preview_bounds: false, align_glyph_bounds_point: false, align_glyph_bounds_area: false, + fade_region_outside_artboard: true, + artboard_update_while_dragging: true, display_print_size: false, double_click_to_isolate: true, transform_pattern_tiles: false, @@ -357,6 +369,7 @@ impl Default for Prefs { zoom_with_mouse_wheel: false, scrub_numeric_fields: true, trackpad_rotate_view: true, + hold_tool_keys: true, paste_offset: 10.0, selection_tolerance: 3.0, object_selection_by_path_only: false, @@ -410,6 +423,7 @@ impl Default for Prefs { measurement_labels: true, spacing_guides: true, distance_guides: true, + snap_to_last_location: false, snapping_tolerance: 4.0, snap_tangent: true, snap_perpendicular: true, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index f2104d75f..ec9fdbac1 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -25,6 +25,8 @@ impl Session { vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || { vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision() }); + // Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs. + vectorcraft_text::register_envelope_ink(); // Exports bake Vector Halftone into the dots the renderer samples. vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art); // OpenType SVG glyphs are drawn as the SVG importer reads their documents. diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 71d2baf46..02f2bb532 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -289,21 +289,42 @@ impl Session { } /// Record in the store what the user granted plug-in `id` (`None`: nothing), leaving the store's - /// other grants as they are; with none left, remove the file. + /// other grants as they are; with none left, remove the file. Entries this version can't use + /// are written back as they were, except one for `id`. A grants file that is too large, can't + /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; + /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. pub(crate) fn store_grant(&self, id: &str, grant: Option) { let Some(dir) = &self.plugin_dir else { return }; - let mut grants = stored_grants(dir); - let changed = match grant { - Some(g) => grants.insert(id.to_string(), g.clone()).as_ref() != Some(&g), - None => grants.remove(id).is_some(), + let mut file = match read_grants(dir) { + Ok(file) => file, + Err(e) => { + log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); + return; + } }; + let others = file.other.len(); + file.other.retain(|entry| entry.get("id").and_then(serde_json::Value::as_str) != Some(id)); + let changed = match grant { + Some(g) => file.grants.insert(id.to_string(), g.clone()).as_ref() != Some(&g), + None => file.grants.remove(id).is_some(), + } || file.other.len() != others; if !changed { return; } - if grants.is_empty() { + let mut entries: Vec = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect(); + entries.extend(file.other); + if entries.is_empty() { take(dir, GRANTS_FILE); - } else if let Ok(bytes) = serde_json::to_vec_pretty(&grants.into_values().collect::>()) { - put(dir, GRANTS_FILE, &bytes); + return; + } + // The file is read back only up to its cap: written compactly when pretty-printed is larger, + // and not at all when that is too. + let fits = |bytes: &Vec| bytes.len() as u64 <= MAX_GRANTS_FILE; + match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) { + Some(bytes) => { + put(dir, GRANTS_FILE, &bytes); + } + None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)), } } @@ -315,20 +336,53 @@ impl Session { } } -/// The grants kept in the plug-in store `dir`, by plug-in id: none when there is no grants file, -/// and none, with a log record, when it is too large or doesn't parse. Grants for an invalid id are -/// left out. -fn stored_grants(dir: &Path) -> BTreeMap { +/// What the grants file of a plug-in store holds. +#[derive(Default)] +struct GrantsFile { + /// The grants this version can use, by plug-in id. + grants: BTreeMap, + /// The entries it can't use (a permission it doesn't know, an invalid id), as they were. + other: Vec, +} + +/// The grants file of the plug-in store `dir`: empty when there is none, an error when it is too +/// large, can't be read or doesn't hold a list. An entry that doesn't parse as a grant, or names an +/// invalid id, is kept in [`GrantsFile::other`] with a log record. +fn read_grants(dir: &Path) -> Result { let path = dir.join(GRANTS_FILE); - if !path.is_file() { - return BTreeMap::new(); + if !path.exists() { + return Ok(GrantsFile::default()); } - let grants = read_capped(&path, MAX_GRANTS_FILE) - .and_then(|bytes| serde_json::from_slice::>(&bytes).map_err(|e| e.to_string())); - match grants { - Ok(grants) => grants.into_iter().filter(|g| vectorcraft_plugins::manifest::valid_id(&g.id)).map(|g| (g.id.clone(), g)).collect(), + let entries = + read_capped(&path, MAX_GRANTS_FILE).and_then(|bytes| serde_json::from_slice::>(&bytes).map_err(|e| e.to_string()))?; + let mut file = GrantsFile::default(); + let mut first_error = None; + for entry in entries { + let why = match serde_json::from_value::(entry.clone()) { + Ok(g) if vectorcraft_plugins::manifest::valid_id(&g.id) => { + file.grants.insert(g.id.clone(), g); + continue; + } + Ok(g) => format!("invalid id `{}`", g.id.escape_debug()), + Err(e) => e.to_string(), + }; + first_error.get_or_insert(why); + file.other.push(entry); + } + if let Some(why) = first_error { + log::warn!("skipping {} stored plug-in grants in {} (the first: {why})", file.other.len(), path.display()); + } + Ok(file) +} + +/// The grants kept in the plug-in store `dir` that this version can use, by plug-in id: none when +/// there is no grants file, and none, with a log record, when it is too large, can't be read or +/// doesn't hold a list. +fn stored_grants(dir: &Path) -> BTreeMap { + match read_grants(dir) { + Ok(file) => file.grants, Err(e) => { - log::warn!("skipping the stored plug-in grants {}: {e}", path.display()); + log::warn!("skipping the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); BTreeMap::new() } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 862bc4b77..b9e247ed9 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -70,6 +70,7 @@ mod gradpresets; mod gradspan; mod guide_clipboard; mod halftone; +mod history_document; mod history_mem; mod ids_without_selection; mod inline; @@ -3616,16 +3617,18 @@ fn given_targets_are_used_as_given_and_range_style_takes_the_selected_type() { } /// #785: commands whose `ids` or `id` the shared check doesn't read (it reads `id` where it stands -/// for `ids` of one) refuse one that isn't an object id. They don't act on the selection or the -/// current layer instead, and the document and its undo history stay as they are. +/// for `ids` of one) refuse one that isn't an object id. They don't act on the selection, the +/// current layer or a new object instead, and the document, the selection and the undo history +/// stay as they are. #[test] fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { let mut s = session(); let a = rect(&mut s, 100.0, 100.0, 80.0, 60.0); let b = rect(&mut s, 140.0, 120.0, 80.0, 60.0); let graph = s.execute("graph.create", &json!({"type": "column", "x": 400, "y": 300, "width": 120, "height": 80, "rows": [[1, 2]]})).unwrap(); + let text = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "type", "size": 30})).unwrap()["id"].clone(); let ids = [json!([a.0, "x"]), json!(3)]; - let id = [json!("2"), json!("$1.id"), json!(2.5)]; + let id = [json!("2"), json!("$1.id"), json!(2.5), json!("abc"), json!(true), json!({})]; let commands = [ ("object.blend.make", json!({}), "ids", &ids[..], json!([a.0, b.0])), ("object.envelope.makeWithWarp", json!({}), "ids", &ids[..], json!([a.0, b.0])), @@ -3635,6 +3638,26 @@ fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { ("perspective.editText", json!({}), "id", &id[..], json!([a.0])), ("layer.clippingMask.toggle", json!({}), "id", &id[..], json!([a.0, b.0])), ("layer.newSublayer", json!({}), "parent", &id[..], json!([a.0])), + ("object.isolate", json!({}), "id", &id[..], json!([a.0])), + ("graphicStyle.new", json!({"name": "From a"}), "id", &id[..], json!([a.0])), + ("charStyle.new", json!({"name": "From type"}), "id", &id[..], json!([text])), + ("paraStyle.new", json!({"name": "From type"}), "id", &id[..], json!([text])), + ("charStyle.apply", json!({"name": "[Normal Character Style]"}), "id", &id[..], json!([text])), + ("type.step", json!({"attribute": "size"}), "id", &id[..], json!([text])), + ("layer.releaseToLayers", json!({"build": true}), "id", &id[..], json!([a.0])), + ("layer.locate", json!({}), "id", &id[..], json!([a.0])), + ("transparency.editOpacityMask", json!({}), "id", &id[..], json!([a.0])), + ("transparency.viewOpacityMask", json!({}), "id", &id[..], json!([a.0])), + ("links.info", json!({}), "id", &id[..], json!([a.0])), + ("links.unembed", json!({}), "id", &id[..], json!([a.0])), + ("links.goTo", json!({}), "id", &id[..], json!([a.0])), + ("shaper.release", json!({}), "id", &id[..], json!([a.0])), + ("shaper.expand", json!({}), "id", &id[..], json!([a.0])), + ("image.info", json!({}), "id", &id[..], json!([a.0])), + ("symbol.edit", json!({}), "id", &id[..], json!([a.0])), + ("object.blend.spine.addAnchor", json!({"x": 120, "y": 110}), "id", &id[..], json!([a.0])), + ("path.curvature", json!({"points": [[0, 0], [50, 40], [100, 0]]}), "id", &id[..], json!([a.0])), + ("css.export", json!({"unnamed": true}), "id", &id[..], json!([a.0])), ]; let mut wrong = vec![]; for (cmd, params, key, values, selected) in commands { @@ -3644,21 +3667,44 @@ fn malformed_ids_are_refused_by_commands_that_default_to_the_selection() { if let Some(p) = p.as_object_mut() { p.insert(key.into(), bad.clone()); } - let (doc, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let (doc, selection, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone(), s.doc().unwrap().history.undo.len()); let r = s.execute(cmd, &p); - let refused = r.as_ref().err().map(|e| e.to_string()).is_some_and(|e| e.contains("object id") || e.contains("step reference")); - let same = s.doc().unwrap().doc == doc && s.doc().unwrap().history.undo.len() == undo; + let named = |e: &str| e.contains("object id") || e.contains("step reference") || e.contains(&format!("`{key}` must be")); + let refused = r.as_ref().err().map(|e| e.to_string()).is_some_and(|e| named(&e)); + let same = s.doc().unwrap().doc == doc && s.doc().unwrap().selection == selection && s.doc().unwrap().history.undo.len() == undo; if !(refused && same) { wrong.push(format!("{cmd} {p}: {r:?}, document unchanged: {same}")); } if !same { - s.execute("edit.undo", &json!({})).ok(); + let st = s.doc_mut().unwrap(); + (st.doc, st.selection) = (doc, selection); + st.history.undo.truncate(undo); } } } assert!(wrong.is_empty(), "{}", wrong.join("\n")); } +/// An omitted or null `id` still means the selection, and an object id names that object. +/// `effect.apply` with the effect given as `id` (its older key) still applies it to the selection, +/// as one undo step. +#[test] +fn an_omitted_id_still_means_the_selection_and_effect_apply_takes_an_effect_as_id() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 80.0, 60.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + for p in [json!({}), json!({"id": null}), json!({"id": a.0})] { + assert_eq!(s.execute("layer.locate", &p).unwrap()["id"], a.0, "{p}"); + } + for p in [json!({"unnamed": true}), json!({"id": null, "unnamed": true}), json!({"id": a.0, "unnamed": true})] { + assert_eq!(s.execute("css.export", &p).unwrap()["rules"], 1, "{p}"); + } + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("effect.apply", &json!({"id": "stylize.dropShadow"})).unwrap(); + assert_eq!(r["ids"], json!([a.0])); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); +} + #[test] fn malformed_anchor_selection_never_replaces_or_partially_changes_selection() { let mut s = session(); diff --git a/crates/engine/src/tests/bboxrotate.rs b/crates/engine/src/tests/bboxrotate.rs index 17c6aac3c..1893f8c18 100644 --- a/crates/engine/src/tests/bboxrotate.rs +++ b/crates/engine/src/tests/bboxrotate.rs @@ -3,7 +3,7 @@ //! the box to the page again. use serde_json::{Value, json}; -use vectorcraft_doc::OrientedBox; +use vectorcraft_doc::{Node, OrientedBox}; use vectorcraft_geom::{Point, Rect}; use vectorcraft_tools::bbox::Handle; use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; @@ -141,6 +141,149 @@ fn reset_bounding_box_squares_the_box_in_one_step() { assert!(near(angle(&s, a), 45.0)); } +/// The angles of every object in `n`'s subtree, opacity mask art included, layers left out. +fn subtree_angles(n: &Node, out: &mut Vec) { + if !n.is_layer() { + out.push(n.bbox_angle); + } + for c in n.children().into_iter().flatten() { + subtree_angles(c, out); + } + if let Some(m) = &n.extras().mask { + subtree_angles(&m.art, out); + } +} + +/// The angles of every object in the document, opacity mask art included, layers left out. +fn object_angles(s: &Session) -> Vec { + let mut out = vec![]; + for l in &s.doc().unwrap().doc.layers { + subtree_angles(l, &mut out); + } + out +} + +#[test] +fn reset_bounding_box_squares_the_objects_inside_a_group() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + let g = id_of(&s.execute("object.group", &json!({})).unwrap()); + s.execute("object.rotate", &json!({"angle": 10})).unwrap(); + assert!(near(angle(&s, g), 10.0) && near(angle(&s, a), 30.0) && near(angle(&s, b), 30.0)); + let n = undo_len(&s); + // The group and both members, in one step. + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 3); + assert_eq!(undo_len(&s), n + 1); + assert!(object_angles(&s).iter().all(|a| *a == 0.0), "{:?}", object_angles(&s)); + s.execute("object.ungroup", &json!({})).unwrap(); + assert_eq!(selection_box(&s).angle, 0.0); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0); + // Undo brings every angle back. + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(near(angle(&s, g), 10.0) && near(angle(&s, a), 30.0) && near(angle(&s, b), 30.0)); +} + +#[test] +fn reset_bounding_box_squares_the_turned_members_of_a_square_group() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 300.0, 100.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + assert_eq!(selection_box(&s).angle, 0.0); + let n = undo_len(&s); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 2); + assert_eq!(undo_len(&s), n + 1); + s.execute("object.ungroup", &json!({})).unwrap(); + assert_eq!(selection_box(&s).angle, 0.0); +} + +/// Two rectangles turned 20°, put in a container by `make`, turned 10° with it and reset, then +/// released by `release`: nothing stands at an angle before or after the release. +fn reset_then_release(make: &str, release: &str) -> Session { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 150.0, 120.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + s.execute(make, &json!({})).unwrap(); + s.execute("object.rotate", &json!({"angle": 10})).unwrap(); + assert!(object_angles(&s).iter().any(|a| near(*a, 30.0)), "{make}: {:?}", object_angles(&s)); + s.execute("object.resetBoundingBox", &json!({})).unwrap(); + assert!(object_angles(&s).iter().all(|a| *a == 0.0), "{make}: {:?}", object_angles(&s)); + s.execute(release, &json!({})).unwrap(); + assert!(object_angles(&s).iter().all(|a| *a == 0.0), "{release}: {:?}", object_angles(&s)); + s.execute("select.all", &json!({})).unwrap(); + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 0, "{release}"); + s +} + +#[test] +fn reset_bounding_box_then_release_clipping_mask() { + reset_then_release("object.clippingMask.make", "object.clippingMask.release"); +} + +#[test] +fn reset_bounding_box_then_release_compound_path() { + reset_then_release("object.compoundPath.make", "object.compoundPath.release"); +} + +#[test] +fn reset_bounding_box_then_expand_blend() { + reset_then_release("object.blend.make", "object.blend.expand"); +} + +#[test] +fn reset_bounding_box_then_release_envelope() { + reset_then_release("object.envelope.makeWithWarp", "object.envelope.release"); +} + +#[test] +fn reset_bounding_box_then_release_repeat() { + reset_then_release("object.repeat.grid", "object.repeat.release"); +} + +#[test] +fn reset_bounding_box_then_release_intertwine() { + reset_then_release("object.intertwine.make", "object.intertwine.release"); +} + +#[test] +fn reset_bounding_box_then_release_linked_opacity_mask() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 150.0, 120.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + let mask = s.doc().unwrap().doc.node(a).unwrap().extras().mask.clone().unwrap(); + assert!(mask.linked && near(mask.art.bbox_angle, 20.0)); + drop(s); + reset_then_release("transparency.makeOpacityMask", "transparency.releaseOpacityMask"); +} + +#[test] +fn reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0); + let b = rect(&mut s, 150.0, 120.0, 100.0, 50.0); + sel(&mut s, &[a, b]); + s.execute("transparency.makeOpacityMask", &json!({})).unwrap(); + s.execute("object.rotate", &json!({"angle": 20})).unwrap(); + s.execute("transparency.unlinkOpacityMask", &json!({})).unwrap(); + let mask_angle = |s: &Session| s.doc().unwrap().doc.node(a).unwrap().extras().mask.as_ref().unwrap().art.bbox_angle; + assert!(near(angle(&s, a), 20.0) && near(mask_angle(&s), 20.0)); + // Only the object: its mask's art no longer turns with it. + assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1); + assert_eq!(angle(&s, a), 0.0); + assert!(near(mask_angle(&s), 20.0)); +} + #[test] fn page_flips_keep_an_upright_box_upright() { let mut s = session(); diff --git a/crates/engine/src/tests/brushsym.rs b/crates/engine/src/tests/brushsym.rs index 4784831ef..638b7a1f4 100644 --- a/crates/engine/src/tests/brushsym.rs +++ b/crates/engine/src/tests/brushsym.rs @@ -1343,6 +1343,150 @@ fn delete_duplicate_and_replace_symbols() { assert!(s.execute("symbol.delete", &json!({"name": "Small"})).is_err()); } +fn xf_of(s: &Session, id: NodeId) -> vectorcraft_geom::Affine { + match node(s, id).kind { + NodeKind::SymbolInstance { xf, .. } => xf, + _ => panic!("{id:?} is not a symbol instance"), + } +} + +fn close_xf(a: vectorcraft_geom::Affine, b: vectorcraft_geom::Affine) -> bool { + a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| (x - y).abs() < 1e-9) +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// Reset Transformation gives rotated, scaled, sheared and reflected instances (in a group too) +/// their symbol's own scale again, each keeping the center of its bounding box, as one undo step. +#[test] +fn reset_transformation_restores_the_symbols_scale_and_keeps_the_center() { + let mut s = session(); + let (a, name, placed) = make_symbol(&mut s); + let b = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 300, "y": 200})).unwrap()["id"].as_u64().unwrap()); + let c = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 100, "y": 220})).unwrap()["id"].as_u64().unwrap()); + let master = xf_of(&s, b).with_translation(vectorcraft_geom::Vec2::ZERO); + select(&mut s, &[a]); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("object.scale", &json!({"sx": 150, "sy": 50})).unwrap(); + select(&mut s, &[b]); + s.execute("object.shear", &json!({"angle": 20})).unwrap(); + select(&mut s, &[c]); + s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap(); + let turned: Vec<_> = [a, b, c].iter().map(|id| xf_of(&s, *id)).collect(); + let centers: Vec<_> = [a, b, c].iter().map(|id| bounds(&s, *id).center()).collect(); + let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[b, c]); + let group = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[a, group, plain]); + let plain_before = node(&s, plain); + let before = undo_len(&s); + let r = s.execute("symbol.instance.resetTransform", &json!({})).unwrap(); + assert_eq!(r["ids"], json!([a.0, b.0, c.0])); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + for (id, center) in [a, b, c].into_iter().zip(¢ers) { + let got = bounds(&s, id); + assert!((got.width() - placed.width()).abs() < 1e-9 && (got.height() - placed.height()).abs() < 1e-9, "{id:?}: {got:?}"); + assert!((got.center() - *center).hypot() < 1e-9, "{id:?} keeps its center: {got:?} vs {center:?}"); + let xf = xf_of(&s, id); + assert!(close_xf(xf.with_translation(vectorcraft_geom::Vec2::ZERO), master), "{id:?}: {xf:?}"); + } + assert!(node(&s, plain) == plain_before, "other objects are left as they are"); + s.execute("edit.undo", &json!({})).unwrap(); + for (id, xf) in [a, b, c].into_iter().zip(turned) { + assert!(close_xf(xf_of(&s, id), xf), "Undo gives {id:?} its own transform back"); + } +} + +/// An instance of a symbol whose art lies away from the symbol's origin (as imported symbols can) +/// keeps the center of the box its art fills when Reset Transformation turns it back. +#[test] +fn reset_transformation_keeps_the_center_of_art_away_from_the_origin() { + let mut s = session(); + let r = rect(&mut s, 300.0, 10.0, 20.0, 20.0); + let art = node(&s, r); + s.execute("edit.clear", &json!({"ids": [r.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Off".into(), art: std::sync::Arc::new(art) }); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(0.5); + d.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Off".into(), xf })).unwrap(); + let measured = |s: &Session| s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + let before = measured(&s); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [id.0]})).unwrap()["ids"], json!([id.0])); + let after = measured(&s); + assert!((after.center() - before.center()).hypot() < 1e-9, "{after:?} vs {before:?}"); + assert!((after.width() - 20.0).abs() < 1e-9 && (after.height() - 20.0).abs() < 1e-9, "{after:?}"); +} + +/// A stored natural size that is not positive (from a damaged file: zero or negative on either +/// side) does not flatten or mirror the instance: Reset Transformation gives it the art's own +/// size, as drawing the symbol does. +#[test] +fn reset_transformation_ignores_a_stored_size_that_is_not_positive() { + for size in [json!([0.0, 20.0]), json!([-20.0, 20.0]), json!([20.0, -5.0]), json!([-20.0, -20.0])] { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 20.0, 20.0); + let art = node(&s, r); + s.execute("edit.clear", &json!({"ids": [r.0]})).unwrap(); + let st = s.doc_mut().unwrap(); + let d = std::sync::Arc::make_mut(&mut st.doc); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Flat".into(), art: std::sync::Arc::new(art) }); + d.unknown.insert(vectorcraft_doc::SYMBOL_SIZES.to_string(), json!({ "Flat": size })); + let layer = d.layers[0].id; + let id = d.alloc_id(); + let xf = vectorcraft_geom::Affine::translate((100.0, 100.0)) * vectorcraft_geom::Affine::rotate(0.5); + d.insert(Some(layer), usize::MAX, Node::new(id, NodeKind::SymbolInstance { symbol: "Flat".into(), xf })).unwrap(); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [id.0]})).unwrap()["ids"], json!([id.0]), "{size}"); + let after = s.doc().unwrap().doc.bounds_of(&[id], false).unwrap(); + assert!((after.width() - 20.0).abs() < 1e-9 && (after.height() - 20.0).abs() < 1e-9, "{size}: {after:?}"); + assert!(close_xf(xf_of(&s, id).with_translation(vectorcraft_geom::Vec2::ZERO), vectorcraft_geom::Affine::IDENTITY), "{size}: not mirrored"); + } +} + +/// Reset Transformation adds no undo step for an instance already at its symbol's scale or for +/// one in perspective, and refuses an id naming no object and objects without an instance, +/// changing nothing. +#[test] +fn reset_transformation_leaves_unchanged_instances_and_refuses_bad_ids() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 1000, "height": 800})).unwrap(); + let (inst, name, _) = make_symbol(&mut s); + let before = undo_len(&s); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [inst.0]})).unwrap()["ids"], json!([])); + assert_eq!(undo_len(&s), before, "nothing changed: no undo step"); + let plain = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + select(&mut s, &[inst]); + s.execute("object.rotate", &json!({"angle": 45})).unwrap(); + let turned = s.doc().unwrap().doc.clone(); + let before = undo_len(&s); + for bad in [json!({"ids": [inst.0, 999_999]}), json!({"id": 999_999}), json!({"ids": [plain.0]}), json!({"ids": "x"})] { + assert!(s.execute("symbol.instance.resetTransform", &bad).is_err(), "{bad}"); + } + assert!(*s.doc().unwrap().doc == *turned && undo_len(&s) == before, "the refusals change nothing"); + // An instance in perspective is left as it is. + s.execute("perspective.grid.preset", &json!({"kind": 2})).unwrap(); + let projected = NodeId(s.execute("symbol.place", &json!({"name": name, "x": 500, "y": 350})).unwrap()["id"].as_u64().unwrap()); + select(&mut s, &[projected]); + s.execute("object.rotate", &json!({"angle": 30})).unwrap(); + s.execute("perspective.attach", &json!({"ids": [projected.0], "plane": "right"})).unwrap(); + let xf = xf_of(&s, projected); + let before = undo_len(&s); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({"ids": [projected.0]})).unwrap()["ids"], json!([])); + assert!(undo_len(&s) == before && close_xf(xf_of(&s, projected), xf), "an instance in perspective stays as it was"); + // Selected with a turned instance, the instance in perspective still stays as it was, and only + // the other one is reset and returned, in one undo step. + select(&mut s, &[inst, projected]); + assert_eq!(s.execute("symbol.instance.resetTransform", &json!({})).unwrap()["ids"], json!([inst.0])); + assert_eq!(undo_len(&s), before + 1, "one undo step"); + assert!(close_xf(xf_of(&s, projected), xf), "the instance in perspective stays as it was"); + let reset = xf_of(&s, inst).with_translation(vectorcraft_geom::Vec2::ZERO); + assert!(close_xf(reset, s.doc().unwrap().doc.symbol_natural_xf(&name)), "the turned instance is reset: {reset:?}"); +} + /// New Symbol and Duplicate Symbol trim the name they are given, as Symbol Options trims /// `newName`; the trimmed name is the one compared with the symbols there are. #[test] diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index cb6ca47c5..f841efedc 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -542,3 +542,112 @@ fn intents_reach_the_document_conversion() { assert_ne!(first(&a), first(&b)); let _ = Intent::Perceptual; } + +/// The error `cmd` gives for `params`: it must name `msg`. +fn refused(s: &mut Session, cmd: &str, params: Value, msg: &str) { + let e = match s.execute(cmd, ¶ms) { + Ok(r) => panic!("{cmd} {params} gave {r}"), + Err(e) => e.to_string(), + }; + assert!(e.contains(msg), "{cmd} {params}: {e}"); +} + +#[test] +fn color_settings_refuse_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let before = cms::active_settings(); + let rev = s.doc().unwrap().revision; + let c = "edit.colorSettings"; + refused(&mut s, c, json!({"bpc": "no"}), "`bpc` must be true or false"); + refused(&mut s, c, json!({"rgb": 5}), "`rgb` must be a string"); + refused(&mut s, c, json!({"cmyk": true}), "`cmyk` must be a string"); + refused(&mut s, c, json!({"intent": 2}), "`intent` must be a string"); + assert_eq!(cms::active_settings(), before, "the settings are unchanged"); + assert_eq!(s.doc().unwrap().revision, rev, "nothing redraws"); + // Omitted and null params keep the settings. + s.execute(c, &json!({"bpc": null, "rgb": null})).unwrap(); + assert_eq!(cms::active_settings(), before); + refused(&mut s, "color.convert", json!({"color": "#808080", "to": 1}), "`to` must be a string"); + refused(&mut s, "color.convert", json!({"color": "#808080", "intent": ["relative"]}), "`intent` must be a string"); + assert_eq!(s.execute("color.convert", &json!({"color": "#808080", "to": null})).unwrap()["model"], "rgb"); + let profiles = || cms::profiles().into_iter().map(|i| i.name).collect::>(); + let listed = profiles(); + refused(&mut s, "color.loadProfile", json!({"path": 7}), "`path` must be a string"); + refused(&mut s, "color.removeProfile", json!({"name": 7}), "`name` must be a string"); + assert_eq!(profiles(), listed, "no profile is loaded or removed"); + assert_eq!(cms::active_settings(), before, "the settings are unchanged"); + assert_eq!(s.doc().unwrap().revision, rev, "nothing redraws"); +} + +#[test] +fn document_color_mode_refuses_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#ff0000")); + let fill = fill_of(&s, a); + let undo = s.doc().unwrap().history.undo.len(); + for c in ["file.documentColorMode", "object.convertDocumentColorMode"] { + refused(&mut s, c, json!({"mode": "cmyk", "convert": "no"}), "`convert` must be true or false"); + refused(&mut s, c, json!({"mode": "rgb", "convert": 0}), "`convert` must be true or false"); + refused(&mut s, c, json!({"mode": "cmyk", "grays": 1}), "`grays` must be a string"); + refused(&mut s, c, json!({"mode": "cmyk", "intent": false}), "`intent` must be a string"); + } + let st = s.doc().unwrap(); + assert_eq!(st.doc.color_mode, ColorMode::Rgb); + assert_eq!(st.history.undo.len(), undo, "no undo step"); + assert_eq!(fill_of(&s, a), fill, "the colors are unchanged"); +} + +#[test] +fn proof_views_refuse_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + let view = vectorcraft_render::proof::view(); + let rev = s.doc().unwrap().revision; + refused(&mut s, "view.proofSetup", json!({"simulatePaper": "yes"}), "`simulatePaper` must be true or false"); + refused(&mut s, "view.proofSetup", json!({"proof": 1}), "`proof` must be true or false"); + refused(&mut s, "view.proofSetup", json!({"target": 3}), "`target` must be a string"); + refused(&mut s, "view.proofColors", json!({"on": "true"}), "`on` must be true or false"); + refused(&mut s, "view.overprintPreview", json!({"on": 1}), "`on` must be true or false"); + refused(&mut s, "view.separationsPreview", json!({"on": "false"}), "`on` must be true or false"); + refused(&mut s, "view.separationsPreview", json!({"plates": "Cyan"}), "`plates` must be an array of these names: Cyan, Magenta"); + refused(&mut s, "view.separationsPreview", json!({"plates": ["Cyan", 2]}), "`plates` must be an array of these names"); + refused(&mut s, "view.separationsPreview", json!({"only": 0}), "`only` must be a string"); + refused(&mut s, "view.separationsPreview", json!({"toggle": ["Cyan"]}), "`toggle` must be a string"); + assert_eq!(vectorcraft_render::proof::view(), view, "the proof view is unchanged"); + assert_eq!(s.doc().unwrap().revision, rev, "nothing redraws"); + // A null `on` toggles, as an omitted one does. + let r = s.execute("view.proofColors", &json!({"on": null})).unwrap(); + assert_eq!(r["proofColors"], !view.proof_colors); + s.execute("view.proofColors", &json!({"on": view.proof_colors})).unwrap(); +} + +#[test] +fn set_spot_refuses_a_param_of_the_wrong_type() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 60, "y": 100, "k": 0}, "global": true})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + refused(&mut s, "swatch.setSpot", json!({"name": "Ink", "spot": "true"}), "`spot` must be true or false"); + refused(&mut s, "swatch.setSpot", json!({"name": 1}), "`name` must be a string"); + assert!(!s.doc().unwrap().doc.swatch("Ink").unwrap().spot, "the swatch stays a process color"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); + s.execute("swatch.setSpot", &json!({"name": "Ink", "spot": null})).unwrap(); + assert!(s.doc().unwrap().doc.swatch("Ink").unwrap().spot, "a null `spot` is the default, true"); +} + +#[test] +fn gamut_check_refuses_colors_that_are_not_a_list() { + let _g = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0, json!("#0000ff")); + s.execute("select.set", &json!({"ids": []})).unwrap(); + refused(&mut s, "color.gamutCheck", json!({"colors": "#00ff00"}), "`colors` must be an array of colors, not \"#00ff00\""); + refused(&mut s, "color.gamutCheck", json!({"colors": 5}), "`colors` must be an array of colors, not 5"); + refused(&mut s, "color.gamutCheck", json!({"colors": {"c": 100, "m": 0, "y": 0, "k": 0}}), "`colors` must be an array of colors"); + // A null list checks the document, as an omitted one does. + let r = s.execute("color.gamutCheck", &json!({"colors": null})).unwrap(); + assert_eq!(r, s.execute("color.gamutCheck", &json!({})).unwrap()); + assert!(r["outOfGamut"].as_array().unwrap().iter().any(|v| v["hex"] == "#0000ff"), "{r}"); +} diff --git a/crates/engine/src/tests/envelope.rs b/crates/engine/src/tests/envelope.rs index 8e3cbf6cc..e4d030d38 100644 --- a/crates/engine/src/tests/envelope.rs +++ b/crates/engine/src/tests/envelope.rs @@ -445,3 +445,470 @@ fn warped_type_in_a_symbol_reopens_unchanged() { let differ = (0..300).flat_map(|y| (0..400).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count(); assert_eq!(differ, 0, "{differ} pixels changed on reopening"); } + +/// The glyph outline bounds of type `id` in document space. +fn ink_of(s: &Session, id: NodeId) -> Rect { + match node(s, id).kind { + NodeKind::Text(t) => t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()), + k => panic!("{k:?}"), + } +} + +fn assert_box(a: Rect, b: Rect, what: &str) { + let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(d < 1e-6, "{what}: {a:?} vs {b:?}"); +} + +/// The box envelope `id` maps its content from. +fn src_of(s: &Session, id: NodeId) -> Rect { + live::EnvelopeMap::of(&node(s, id)).unwrap().src() +} + +/// Envelopes map point type, area type and type in a group from the glyph outline bounds, not +/// from the laid-out box (ascent to descent, or the area's frame): with a flat warp the distorted +/// type sits where the type was, and Make with Mesh lays its grid over the glyphs. +#[test] +fn envelopes_map_type_from_its_glyph_outline_bounds() { + let mut s = session(); + let point = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Envelope", "size": 48})).unwrap()); + let area = id_of( + &s.execute("text.create", &json!({"x": 100, "y": 300, "text": "Area type", "size": 36, "area": {"width": 300, "height": 200}})).unwrap(), + ); + let grouped = id_of(&s.execute("text.create", &json!({"x": 450, "y": 200, "text": "Group", "size": 40})).unwrap()); + sel(&mut s, &[grouped]); + let group = id_of(&s.execute("object.group", &json!({})).unwrap()); + for (t, target) in [(point, point), (area, area), (grouped, group)] { + let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, target))], Affine::IDENTITY).unwrap(); + assert_box(plain, ink, "geometric box"); + assert_box(stroked, ink, "box with strokes (none)"); + sel(&mut s, &[target]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), ink, "warp"); + assert_box(node(&s, e).geometric_bounds().unwrap(), ink, "flat warp's bounds"); + s.execute("edit.undo", &json!({})).unwrap(); + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap()); + let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") }; + let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p)); + assert_box(grid, ink, "mesh grid"); + assert_box(src_of(&s, e), ink, "mesh"); + s.execute("edit.undo", &json!({})).unwrap(); + } +} + +/// Shrink Text to Fit lays type out by its size on the page: after the envelope is scaled, it +/// maps from the glyphs of the type laid out at its new size, not those measured before. +#[test] +fn a_scaled_envelope_of_shrunk_type_maps_from_its_new_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap()); + s.edit("shrink", |d, _| { + if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind { + tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 }; + } + Ok(()) + }) + .unwrap(); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let text = |s: &Session| match node(s, e).kind { + NodeKind::Envelope { content, .. } => match &content[0].kind { + NodeKind::Text(tx) => tx.clone(), + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + let ink = |tx: &vectorcraft_doc::TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).ink_bounds().unwrap(); + let before = text(&s); + assert_box(src_of(&s, e), before.xf.transform_rect_bbox(ink(&before)), "before scaling"); + s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap(); + let after = text(&s); + let fresh = after.xf.transform_rect_bbox(ink(&after)); + let stale = after.xf.transform_rect_bbox(ink(&before)); + assert!((fresh.width() - stale.width()).abs() > 1e-3 || (fresh.height() - stale.height()).abs() > 1e-3, "{fresh:?} vs {stale:?}"); + assert_box(src_of(&s, e), fresh, "after scaling"); +} + +/// A rotated envelope keeps a frame of its own: shrunk type in it is measured as it is laid out on +/// the page, and its box is then taken into the frame. +#[test] +fn a_rotated_envelope_of_shrunk_type_maps_from_the_glyphs_on_the_page() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Type", "size": 2, "area": {"width": 3, "height": 3}})).unwrap()); + s.edit("shrink", |d, _| { + if let NodeKind::Text(tx) = &mut d.node_mut(t).unwrap().kind { + tx.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 }; + } + Ok(()) + }) + .unwrap(); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + s.execute("object.rotate", &json!({"angle": 30, "origin": [100, 100]})).unwrap(); + s.execute("object.scale", &json!({"sx": 150, "sy": 150, "origin": [100, 100]})).unwrap(); + let (frame, tx) = match node(&s, e).kind { + NodeKind::Envelope { frame, content, .. } => match &content[0].kind { + NodeKind::Text(tx) => (*frame, tx.clone()), + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + }; + assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame"); + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap(); + assert_box(src_of(&s, e), (frame.inverse() * tx.xf).transform_rect_bbox(ink), "in the frame"); +} + +/// The bars of underlined type are part of what it draws, so the envelope maps from them too and +/// its bounds take them in. +#[test] +fn an_envelope_maps_underlined_type_from_its_glyphs_and_bars() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap()); + s.execute("text.setFormat", &json!({"ids": [t.0], "underline": true})).unwrap(); + let glyphs = ink_of(&s, t); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let src = src_of(&s, e); + assert!(src.y1 > glyphs.y1 + 1.0, "the underline below the glyphs {glyphs:?} is in the box {src:?}"); + assert_box(node(&s, e).geometric_bounds().unwrap(), src, "flat warp's bounds"); + let drawn = vectorcraft_render::effects::expanded_live_group(Some(&s.doc().unwrap().doc), &node(&s, e)).geometric_bounds().unwrap(); + let off = [drawn.x0 - src.x0, drawn.y0 - src.y0, drawn.x1 - src.x1, drawn.y1 - src.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(off < 0.05, "the drawn art {drawn:?} fills the box {src:?}"); +} + +/// Undo of Make Envelope gives the type back with its laid-out box, and redo maps it from its +/// glyphs again. +#[test] +fn undo_and_redo_of_an_envelope_of_type_keep_the_glyph_mapping() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Undo", "size": 60})).unwrap()); + let (ink, laid) = (ink_of(&s, t), node(&s, t).geometric_bounds().unwrap()); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 30})).unwrap()); + let made = node(&s, e).geometric_bounds().unwrap(); + assert_box(src_of(&s, e), ink, "made"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(e).is_none()); + assert_box(node(&s, t).geometric_bounds().unwrap(), laid, "the type after undo"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_box(src_of(&s, e), ink, "redone"); + assert_box(node(&s, e).geometric_bounds().unwrap(), made, "redone bounds"); +} + +/// A saved envelope of type reopens drawn by the same rule: from the glyph outline bounds, which +/// the file does not store. +#[test] +fn a_saved_envelope_of_type_reopens_mapped_from_its_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Saved", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "flag", "bend": 40})).unwrap()); + let render = |s: &Session| vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 600, 400, Affine::IDENTITY, &Default::default()); + let before = render(&s); + let dir = std::env::temp_dir().join(format!("vectorcraft-envelope-ink-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("type.vectorcraft"); + s.execute("document.save", &json!({"path": path})).unwrap(); + let mut o = Session::new(); + o.execute("document.open", &json!({"path": path})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); + assert_box(src_of(&o, e), ink, "reopened"); + let after = render(&o); + let differ = (0..400).flat_map(|y| (0..600).map(move |x| (x, y))).filter(|&(x, y)| before.pixel(x, y) != after.pixel(x, y)).count(); + assert_eq!(differ, 0, "{differ} pixels changed on reopening"); +} + +/// The type of envelope `e`'s first content object. +fn content_text(s: &Session, e: NodeId) -> vectorcraft_doc::TextObject { + match node(s, e).kind { + NodeKind::Envelope { content, .. } => match &content[0].kind { + NodeKind::Text(tx) => (**tx).clone(), + k => panic!("{k:?}"), + }, + k => panic!("{k:?}"), + } +} + +/// The box of `tx`'s glyph outlines with text space mapped by `m`, each outline mapped point by point. +fn outlines_in(tx: &vectorcraft_doc::TextObject, m: Affine) -> Rect { + let path = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath(); + kurbo::Shape::bounding_box(&(m * path)) +} + +/// Rotated type maps from the box of its rotated glyph outlines, which is smaller than the box of +/// its rotated glyph box; in a rotated envelope whose frame turns by another angle than the type, +/// it maps from the outlines taken into the frame. +#[test] +fn an_envelope_of_rotated_type_maps_from_its_rotated_outlines() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 200, "y": 300, "text": "Rotated", "size": 60})).unwrap()); + sel(&mut s, &[t]); + s.execute("object.rotate", &json!({"angle": 40, "origin": [200, 300]})).unwrap(); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let tx = content_text(&s, e); + let outlines = outlines_in(&tx, tx.xf); + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap(); + let glyph_box = tx.xf.transform_rect_bbox(ink); + assert!(glyph_box.width() - outlines.width() > 1.0 || glyph_box.height() - outlines.height() > 1.0, "{glyph_box:?} vs {outlines:?}"); + assert_box(src_of(&s, e), outlines, "rotated type"); + s.execute("object.rotate", &json!({"angle": 15, "origin": [200, 300]})).unwrap(); + let (frame, tx) = match node(&s, e).kind { + NodeKind::Envelope { frame, .. } => (*frame, content_text(&s, e)), + k => panic!("{k:?}"), + }; + assert!(frame != Affine::IDENTITY, "a rotated envelope has a frame"); + let in_frame = frame.inverse() * tx.xf; + assert!(in_frame.as_coeffs()[1].abs() > 0.1, "the type is turned against the frame: {in_frame:?}"); + assert_box(src_of(&s, e), outlines_in(&tx, in_frame), "in the frame"); +} + +/// Type in perspective maps from the box of its outlines as its projection draws them, which is +/// smaller than the projected box of its glyphs. Where the projection can't map a point (past its +/// horizon), the point counts where it is, as the renderer draws it. +#[test] +fn an_envelope_of_type_in_perspective_maps_from_its_projected_outlines() { + use vectorcraft_geom::Homography; + // Every point of the type is past this one's horizon. + let horizon = Homography([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]]); + for (h, crosses) in [(Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]), false), (horizon, true)] { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Perspective", "size": 60})).unwrap()); + s.edit("perspective", |d, _| { + let n = d.node_mut(t).unwrap(); + n.extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment { + projection: Some(h.to_array()), + ..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0) + })); + Ok(()) + }) + .unwrap(); + assert!(node(&s, t).projection().is_some()); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") }; + let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).to_bezpath(); + let (mut drawn, mut unmapped): (Option, usize) = (None, 0); + kurbo::flatten(&path, 0.001, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + let q = h.apply(p).unwrap_or_else(|| { + unmapped += 1; + p + }); + drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + }); + let drawn = drawn.unwrap(); + assert_eq!(unmapped > 0, crosses, "{unmapped} points past the horizon"); + if !crosses { + let ink = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx).ink_bounds().unwrap(); + let boxed = h.map_rect_bbox(tx.xf.transform_rect_bbox(ink)).unwrap(); + assert!(boxed.width() - drawn.width() > 1.0 || boxed.height() - drawn.height() > 1.0, "{boxed:?} vs {drawn:?}"); + } + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + let src = src_of(&s, e); + let off = [src.x0 - drawn.x0, src.y0 - drawn.y0, src.x1 - drawn.x1, src.y1 - drawn.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(off < 0.05, "{src:?} vs {drawn:?}"); + } +} + +/// Type that draws nothing (only spaces) keeps its laid-out box. +#[test] +fn an_envelope_of_type_that_draws_nothing_maps_from_its_laid_out_box() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": " ", "size": 48})).unwrap()); + let laid = node(&s, t).geometric_bounds().unwrap(); + assert_box(live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, t))]).unwrap(), laid, "spaces"); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), laid, "warp"); +} + +/// The strikethrough bar runs to the last glyph's advance, past its outline: the envelope maps +/// from it too. +#[test] +fn an_envelope_maps_struck_through_type_from_its_glyphs_and_bars() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "HH", "size": 100})).unwrap()); + let plain = ink_of(&s, t); + s.execute("text.setFormat", &json!({"ids": [t.0], "strikethrough": true})).unwrap(); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") }; + let db = vectorcraft_text::FontDb::global(); + let laid = vectorcraft_text::layout(db, &tx); + let bars = vectorcraft_text::decorations(&laid, db, &tx); + assert!(!bars.is_empty(), "struck-through type has a bar"); + let bar = tx.xf.transform_rect_bbox(kurbo::Shape::bounding_box(&bars[0].1)); + assert!(bar.x1 > plain.x1 + 1.0, "the bar {bar:?} reaches past the glyphs {plain:?}"); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), plain.union(bar), "glyphs and bar"); +} + +/// In a layer, the visible type is measured by its glyphs and hidden type isn't measured; in a +/// clip group whose clipping path is type, the envelope maps from that type's glyphs. +#[test] +fn type_in_layers_and_clip_groups_is_measured_by_its_glyphs() { + let mut s = session(); + let shown = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Shown", "size": 48})).unwrap()); + let hidden = id_of(&s.execute("text.create", &json!({"x": 100, "y": 500, "text": "Hidden", "size": 48})).unwrap()); + sel(&mut s, &[hidden]); + s.execute("object.hide", &json!({})).unwrap(); + let ink = ink_of(&s, shown); + let layer = s.doc().unwrap().doc.layers[0].clone(); + assert_box(live::envelope_content_bounds(&[layer]).unwrap(), ink, "layer"); + + let mut s = session(); + let back = rect(&mut s, 50.0, 100.0, 500.0, 200.0); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Clip", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[back, t]); + let g = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap()); + assert!(matches!(node(&s, g).kind, NodeKind::Group { clip: true, .. })); + let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(node(&s, g))], Affine::IDENTITY).unwrap(); + assert_box(plain, ink, "clip group"); + assert_box(stroked, ink, "clip group with strokes (none)"); + sel(&mut s, &[g]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), ink, "warp of the clip group"); +} + +/// Stroked type: the box with strokes reaches past the glyphs as far as the type's strokes reach +/// past its laid-out box. In perspective, the glyphs are the projected outlines and the reach is +/// that of the strokes past the projected laid-out box. +#[test] +fn stroked_type_maps_from_its_glyphs_grown_by_its_strokes() { + let h = vectorcraft_geom::Homography([[1.0, 0.3, 0.0], [0.1, 1.0, 0.0], [0.0015, 0.0008, 1.0]]); + for perspective in [false, true] { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Stroke", "size": 60})).unwrap()); + sel(&mut s, &[t]); + s.execute("paint.setStroke", &json!({"color": "#ff0000"})).unwrap(); + s.execute("stroke.set", &json!({"weight": 6})).unwrap(); + if perspective { + s.edit("perspective", |d, _| { + d.node_mut(t).unwrap().extras_mut().perspective = Some(Box::new(vectorcraft_doc::PerspectiveAttachment { + projection: Some(h.to_array()), + ..vectorcraft_doc::PerspectiveAttachment::new("left", 0.0) + })); + Ok(()) + }) + .unwrap(); + } + let n = node(&s, t); + assert_eq!(n.projection().is_some(), perspective); + let (v, g) = (n.visual_bounds().unwrap(), n.geometric_bounds().unwrap()); + assert!(v.x0 < g.x0 - 1.0, "the strokes {v:?} reach past the laid-out box {g:?}"); + let NodeKind::Text(tx) = &n.kind else { panic!("type") }; + // The glyph outlines as drawn. + let ink = if perspective { + let path = tx.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), tx).to_bezpath(); + let mut drawn: Option = None; + kurbo::flatten(&path, 0.001, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + let q = h.apply(p).unwrap(); + drawn = Some(drawn.map_or(Rect::from_points(q, q), |r| r.union_pt(q))); + } + }); + drawn.unwrap() + } else { + ink_of(&s, t) + }; + let (stroked, plain) = live::envelope_content_boxes(&[std::sync::Arc::new(n.clone())], Affine::IDENTITY).unwrap(); + let near = |a: Rect, b: Rect, what: &str| { + let d = [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + assert!(d < 0.05, "{what} (perspective {perspective}): {a:?} vs {b:?}"); + }; + near(plain, ink, "geometric box"); + near(stroked, Rect::new(ink.x0 - (g.x0 - v.x0), ink.y0 - (g.y0 - v.y0), ink.x1 + (v.x1 - g.x1), ink.y1 + (v.y1 - g.y1)), "with strokes"); + } +} + +/// Make with Top Object maps type from its glyphs onto the path's outline. +#[test] +fn a_top_object_envelope_of_type_maps_from_its_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Top", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + let top = rect(&mut s, 80.0, 100.0, 300.0, 150.0); + sel(&mut s, &[t, top]); + let e = id_of(&s.execute("object.envelope.makeWithTopObject", &json!({})).unwrap()); + assert!(matches!(kind(&s, e), EnvelopeKind::TopObject { .. })); + assert_box(src_of(&s, e), ink, "top object"); +} + +/// Type in a symbol instance is measured as before: the envelope maps from the instance's own +/// bounds, not from the type's glyphs. +#[test] +fn an_envelope_of_a_symbol_instance_of_type_maps_from_the_instance_s_bounds() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Symbol", "size": 60})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[t]); + s.execute("symbol.new", &json!({"name": "Type"})).unwrap(); + let inst = s.doc().unwrap().selection.objects[0]; + assert!(matches!(node(&s, inst).kind, NodeKind::SymbolInstance { .. })); + let own = node(&s, inst).geometric_bounds().unwrap(); + assert!((own.height() - ink.height()).abs() > 1.0, "{own:?} vs {ink:?}"); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), own, "symbol instance"); +} + +/// Type inside a blend or a repeat is measured as before: the envelope maps from the object's own +/// bounds, not from the type's glyphs. +#[test] +fn an_envelope_of_a_blend_or_repeat_of_type_maps_from_its_own_bounds() { + for repeat in [false, true] { + let mut s = session(); + let a = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Blend", "size": 60})).unwrap()); + let mut parts = vec![a]; + if !repeat { + parts.push(id_of(&s.execute("text.create", &json!({"x": 300, "y": 400, "text": "Blend", "size": 60})).unwrap())); + } + let ink = parts.iter().map(|t| ink_of(&s, *t)).reduce(|a, b| a.union(b)).unwrap(); + sel(&mut s, &parts); + let made = if repeat { "object.repeat.grid" } else { "object.blend.make" }; + let holder = id_of(&s.execute(made, &json!({})).unwrap()); + let own = node(&s, holder).geometric_bounds().unwrap(); + let measured = live::envelope_content_bounds(&[std::sync::Arc::new(node(&s, holder))]).unwrap(); + assert_box(measured, own, made); + assert!((own.height() - ink.height()).abs() > 1.0 || (own.width() - ink.width()).abs() > 1.0, "{made}: {own:?} vs {ink:?}"); + sel(&mut s, &[holder]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), own, made); + } +} + +/// An inline graphic counts by its box: the envelope maps from the glyph outlines and the box of +/// the graphic, which reaches past the glyphs. +#[test] +fn an_envelope_maps_type_with_an_inline_graphic_from_its_glyphs_and_the_graphic() { + let mut s = session(); + s.execute("shape.ellipse", &json!({"x": 600, "y": 500, "width": 40, "height": 40})).unwrap(); + s.execute("symbol.new", &json!({"name": "Dot"})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "xx", "size": 40})).unwrap()); + s.execute("text.insertInline", &json!({"id": t.0, "at": 1, "symbol": "Dot"})).unwrap(); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!("type") }; + let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &tx); + let [inline] = laid.inlines.as_slice() else { panic!("one inline graphic: {}", laid.inlines.len()) }; + let graphic = tx.xf.transform_rect_bbox(inline.bounds); + let glyphs = outlines_in(&tx, tx.xf); + assert!(graphic.y0 < glyphs.y0 - 1.0, "the graphic {graphic:?} reaches above the glyphs {glyphs:?}"); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "arc", "bend": 0})).unwrap()); + assert_box(src_of(&s, e), glyphs.union(graphic), "glyphs and graphic"); +} + +/// Reset with Mesh without Maintain Shape lays its flat grid over the glyphs. +#[test] +fn reset_with_mesh_lays_a_flat_grid_over_the_glyphs() { + let mut s = session(); + let t = id_of(&s.execute("text.create", &json!({"x": 100, "y": 200, "text": "Reset", "size": 72})).unwrap()); + let ink = ink_of(&s, t); + sel(&mut s, &[t]); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": "bulge", "bend": 40})).unwrap()); + s.execute("object.envelope.resetWithMesh", &json!({"rows": 2, "cols": 3, "maintainShape": false})).unwrap(); + let EnvelopeKind::Mesh { points, .. } = kind(&s, e) else { panic!("mesh") }; + let grid = points.iter().fold(Rect::from_points(points[0], points[0]), |r, p| r.union_pt(*p)); + assert_box(grid, ink, "flat grid"); +} diff --git a/crates/engine/src/tests/flatten.rs b/crates/engine/src/tests/flatten.rs index d4671c11e..2e08b99c8 100644 --- a/crates/engine/src/tests/flatten.rs +++ b/crates/engine/src/tests/flatten.rs @@ -272,6 +272,62 @@ fn balance_trades_vector_regions_for_an_image() { assert_eq!(flatten(&mut s, json!({"ids": [a, b], "balance": 5})).2, 1); } +/// The balance's region limit, `10^(balance/20)` rounded down, is the most regions a group keeps. +#[test] +fn the_region_limit_is_the_most_regions_the_balance_keeps() { + use crate::cmd::FlattenOptions; + for balance in [0.0, 1.0, 5.0, 16.0, 17.0, 20.0, 40.0, 60.0, 75.0, 99.0, 99.99, 100.0] { + for clip in [false, true] { + let o = FlattenOptions::from_params(&json!({"balance": balance, "clipComplexRegions": clip})).unwrap(); + match o.region_limit() { + Some(n) => { + assert!(!o.rasterize_all(n) && o.rasterize_all(n + 1), "balance {balance}: limit {n}"); + assert!(!clip, "balance {balance}: a limit with Clip Complex Regions"); + } + None => assert!(clip || balance <= 0.0 || balance >= 100.0, "balance {balance}: no limit"), + } + } + } +} + +/// A row of four half-transparent rectangles splits into 7 regions. Under the balance's region +/// limit (10), at it (7), just over it (6) and far over it (1), Flatten Transparency, the flattened +/// document and the EPS file come out as they do when every region is built. +#[test] +fn groups_over_the_region_limit_flatten_as_with_every_region_built() { + use crate::cmd::FlattenOptions; + use crate::cmd::flatten::{flatten_document, with_every_region as every}; + let _settings = settings_lock(); + let setup = || { + let mut s = session(); + let ids: Vec = (0..4).map(|k| rect(&mut s, 10.0 + 30.0 * f64::from(k), 10.0, 40.0, 60.0, "#ff8000")).collect(); + run(&mut s, "transparency.set", json!({"ids": ids, "opacity": 50})); + (s, ids) + }; + let eps = |s: &mut Session, balance: u32| { + let r = run(s, "document.exportEps", json!({"flattener": {"balance": balance}, "previewFormat": "none", "thumbnails": false})); + let bytes = vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap(); + String::from_utf8_lossy(&bytes).lines().filter(|l| !l.contains("CreationDate")).collect::>().join("\n") + }; + for (balance, vector, rasterized) in [(20, 7, 0), (17, 7, 0), (16, 0, 1), (5, 0, 1)] { + let (mut s, _) = setup(); + let o = FlattenOptions::from_params(&json!({"balance": balance})).unwrap(); + let src = s.doc().unwrap().doc.clone(); + let flat = flatten_document(&src, &o).unwrap(); + assert!(flat == every(|| flatten_document(&src, &o).unwrap()), "balance {balance}: flattened document"); + assert_eq!(eps(&mut s, balance), every(|| eps(&mut s, balance)), "balance {balance}: EPS"); + let (mut s, ids) = setup(); + let got = flatten(&mut s, json!({"ids": ids, "balance": balance})); + assert_eq!((got.1, got.2), (vector, rasterized), "balance {balance}"); + let (mut t, ids) = setup(); + let want = every(|| flatten(&mut t, json!({"ids": ids, "balance": balance}))); + assert_eq!(got, want, "balance {balance}"); + // The two documents were made apart, so their creation times can differ. + let (a, b) = (&s.doc().unwrap().doc, &t.doc().unwrap().doc); + assert!(a.layers == b.layers && a.images == b.images, "balance {balance}: Flatten Transparency"); + } +} + #[test] fn gradients_rasterize_and_flat_areas_stay_vector() { let _settings = settings_lock(); diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs new file mode 100644 index 000000000..0c78b1109 --- /dev/null +++ b/crates/engine/src/tests/history_document.rs @@ -0,0 +1,361 @@ +//! History panel › New Document from State (`edit.newDocumentFromState`). + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use super::{rect, session}; +use crate::tests_links::{Folder, RED, place, png, write}; +use crate::*; + +const CMD: &str = "edit.newDocumentFromState"; + +fn objects(s: &Session) -> usize { + s.doc().unwrap().doc.layers[0].children().unwrap().len() +} + +fn x(s: &Session, id: NodeId) -> f64 { + s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap().x0 +} + +fn steps(s: &Session) -> (usize, usize) { + let h = &s.doc().unwrap().history; + (h.undo.len(), h.redo.len()) +} + +/// Three rectangles, the last one undone: rows 0 to 3 hold 0 to 3 of them, the current one row 2. +fn three_rectangles_one_undone() -> Session { + let mut s = session(); + for x in [10.0, 200.0, 400.0] { + rect(&mut s, x, 10.0, 100.0, 50.0); + } + s.execute("edit.undo", &json!({})).unwrap(); + s +} + +/// Each row gives the art of its state, the steps to redo included. An omitted or null index gives +/// the current state. Each new document is added and made active. +#[test] +fn a_new_document_holds_the_art_of_the_state() { + let params = [ + (json!({"index": 0}), 0), + (json!({"index": 1}), 1), + (json!({"index": 2}), 2), + (json!({"index": 3}), 3), + (json!({}), 2), + (json!({"index": null}), 2), + ]; + for (p, art) in params { + let mut s = three_rectangles_one_undone(); + let r = s.execute(CMD, &p).unwrap(); + assert_eq!((r["index"].as_u64(), s.active_index()), (Some(1), Some(1)), "{p}"); + assert_eq!(objects(&s), art, "{p}"); + assert_eq!(s.documents().len(), 2); + } +} + +/// The source's document, steps, selection and saved state stay as they are, for a modified source +/// and for one at its saved state. +#[test] +fn the_source_stays_as_it_is() { + let mut s = three_rectangles_one_undone(); + let source = s.doc().unwrap().doc.clone(); + let selection = s.doc().unwrap().selection.clone(); + assert!(s.doc().unwrap().is_dirty()); + s.execute(CMD, &json!({"index": 0})).unwrap(); + let st = &s.documents()[0]; + assert!(Arc::ptr_eq(&st.doc, &source)); + assert_eq!((st.history.undo.len(), st.history.redo.len()), (2, 1)); + assert_eq!(st.selection, selection); + assert!(st.is_dirty()); + + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.doc().unwrap().is_dirty()); + s.execute(CMD, &json!({"index": 1})).unwrap(); + assert!(!s.documents()[0].is_dirty(), "the source stays at its saved state"); + assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved"); +} + +/// An index that names no state, or is not a whole number, is an error and changes nothing: no +/// document, no step and no Untitled number is used up. +#[test] +fn a_bad_index_is_an_error_and_changes_nothing() { + let mut s = three_rectangles_one_undone(); + let source = s.doc().unwrap().doc.clone(); + let untitled = s.peek_untitled(); + let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)]; + for index in bad { + let e = s.execute(CMD, &json!({ "index": index })).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}"); + assert!(e.to_string().contains(&format!("no history state {index}")), "{e}"); + assert_eq!(s.documents().len(), 1, "{index}"); + assert_eq!(steps(&s), (2, 1), "{index}"); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}"); + assert_eq!(s.peek_untitled(), untitled, "{index}"); + } + let e = s.execute(CMD, &json!({"created": "today"})).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{e}"); + assert_eq!(s.documents().len(), 1); +} + +/// With no document the command is disabled. +#[test] +fn it_needs_a_document() { + let mut s = Session::new(); + assert!(matches!(s.execute(CMD, &json!({})), Err(EngineError::Disabled(..)))); +} + +/// An edit in either document leaves the other as it is, and so does taking an artboard id. +#[test] +fn the_two_documents_are_independent() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + rect(&mut s, 200.0, 10.0, 100.0, 50.0); + s.execute(CMD, &json!({"index": 1})).unwrap(); + s.execute("object.move", &json!({"ids": [a.0], "dx": 30, "dy": 0})).unwrap(); + rect(&mut s, 400.0, 10.0, 100.0, 50.0); + assert_eq!((x(&s, a), objects(&s)), (40.0, 2)); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!((x(&s, a), objects(&s)), (10.0, 2), "the source"); + s.execute("object.move", &json!({"ids": [a.0], "dx": 5, "dy": 0})).unwrap(); + assert_eq!(x(&s, a), 15.0); + let source_board = s.doc().unwrap().doc.next_artboard_id(); + s.execute("document.activate", &json!({"index": 1})).unwrap(); + assert_eq!((x(&s, a), objects(&s)), (40.0, 2), "the new document"); + assert!(s.doc().unwrap().doc.retired_artboard_id.get() < source_board, "an artboard id the source took"); +} + +/// The new document starts with no steps, no selection, no path and the next untitled name, and is +/// unsaved. Undo and Redo then work in it alone, and undoing back to the start leaves it unsaved. +#[test] +fn undo_in_the_new_document() { + let mut s = three_rectangles_one_undone(); + s.execute("select.all", &json!({})).unwrap(); + let r = s.execute(CMD, &json!({"index": 1})).unwrap(); + assert_eq!(r["title"], "Untitled-2"); + let st = s.doc().unwrap(); + assert_eq!(st.title(), "Untitled-2"); + assert_eq!(st.path, None); + assert!(st.selection.objects.is_empty()); + assert!(st.is_dirty()); + let start = st.doc.clone(); + assert_eq!(steps(&s), (0, 0)); + assert!(matches!(s.execute("edit.undo", &json!({})), Err(EngineError::Disabled(..))), "the source's steps are not its own"); + rect(&mut s, 600.0, 10.0, 100.0, 50.0); + assert_eq!(objects(&s), 2); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(objects(&s), 1); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &start)); + assert!(s.doc().unwrap().is_dirty(), "still unsaved at the state it started from"); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(objects(&s), 2); + assert_eq!(s.documents()[0].history.undo.len(), 2, "the source's steps"); +} + +/// A drag in progress is first kept as a step and its change goes into the new document. An index +/// names the state as listed before that, and a bad one leaves the drag going on. +#[test] +fn a_drag_in_progress_is_kept_first() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); + assert!(s.execute(CMD, &json!({"index": 2})).is_err()); + assert!(s.in_interaction(), "a bad index ends nothing"); + assert_eq!(steps(&s), (1, 0)); + s.execute(CMD, &json!({"index": 0})).unwrap(); + assert_eq!(objects(&s), 0, "the row listed as 0"); + assert_eq!(x_of_source(&s, a), 40.0); + assert_eq!(s.documents()[0].history.undo.len(), 2, "the drag is a step in the source"); + + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.begin_interaction("Move").unwrap(); + s.preview("object.move", &json!({"dx": 30, "dy": 0})).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(x(&s, a), 40.0, "the current state holds the drag"); + assert_eq!(x_of_source(&s, a), 40.0); + assert!(s.documents()[0].interaction.is_none()); +} + +fn x_of_source(s: &Session, id: NodeId) -> f64 { + s.documents()[0].doc.node(id).unwrap().geometric_bounds().unwrap().x0 +} + +/// A state saved while editing a symbol gives a document out of that mode and without the +/// temporary editing layer. The source stays in the mode. +#[test] +fn a_new_document_leaves_the_editing_modes() { + let mut s = session(); + rect(&mut s, 10.0, 10.0, 100.0, 50.0); + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + let layers = s.doc().unwrap().doc.layers.len(); + s.execute("symbol.edit", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.symbol_edit.is_some()); + assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1); + s.execute(CMD, &json!({})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.symbol_edit.is_none()); + assert_eq!(st.doc.layers.len(), layers); + assert!(s.documents()[0].doc.symbol_edit.is_some()); +} + +/// A state saved while editing a pattern gives a document out of that mode, without the editing +/// layer, and with the art drawn in the tile stored in the pattern, as Done stores it. The source +/// stays in the mode. +#[test] +fn a_new_document_finishes_pattern_editing() { + let mut s = session(); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + let layers = s.doc().unwrap().doc.layers.len(); + s.execute("object.pattern.make", &json!({"name": "P"})).unwrap(); + rect(&mut s, 2.0, 2.0, 3.0, 3.0); + assert_eq!(s.doc().unwrap().doc.layers.len(), layers + 1); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let st = s.doc().unwrap(); + assert!(st.doc.pattern_edit.is_none()); + assert_eq!(st.doc.layers.len(), layers); + assert_eq!(st.doc.pattern("P").map(|p| p.art.len()), Some(2)); + assert_eq!(st.isolation, None); + assert!(s.documents()[0].doc.pattern_edit.is_some()); +} + +/// A linked image still finds its file from the new document, which has no folder of its own, and +/// its pixels come along. +#[test] +fn links_resolve_in_the_new_document() { + let dir = Folder::new("history-document-links"); + let pic = dir.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + let list = |s: &mut Session| s.execute("links.list", &json!({})).unwrap(); + assert_eq!(list(&mut s)["links"][0]["status"], "ok"); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let links = list(&mut s); + assert_eq!((&links["links"][0]["status"], &links["links"][0]["path"]), (&json!("ok"), &json!(pic)), "{links}"); + let st = s.doc().unwrap(); + assert!(st.doc.node(id).is_some()); + assert_eq!(st.doc.images.len(), s.documents()[0].doc.images.len()); +} + +/// A linked file the source finds in its own folder, away from the link's path, is found from the +/// new document too: its link takes the path where the source finds the file. +#[test] +fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() { + let a = Folder::new("history-document-moved-a"); + let b = Folder::new("history-document-moved-b"); + let pic = a.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &pic); + s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + let moved = b.file("photo.png"); + std::fs::rename(&pic, &moved).unwrap(); + let check = |s: &mut Session| s.execute("links.check", &json!({})).unwrap(); + assert_eq!(check(&mut s)["links"][0]["status"], "ok", "the source finds it by name in its folder"); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let c = check(&mut s); + assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}"); +} + +/// The same holds for a linked image only a symbol shows. +#[test] +fn a_file_a_symbol_shows_is_found_from_the_new_document() { + let a = Folder::new("history-document-symbol-a"); + let b = Folder::new("history-document-symbol-b"); + let pic = a.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &pic); + s.execute("symbol.new", &json!({"name": "Photo"})).unwrap(); + s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + let moved = b.file("photo.png"); + std::fs::rename(&pic, &moved).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let d = &s.doc().unwrap().doc; + assert!(!d.symbols.is_empty()); + let mut paths = vec![]; + d.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone()))); + assert!(!paths.is_empty() && paths.iter().all(|p| *p == moved), "{paths:?}"); +} + +/// Files are looked for only within the roots of the caller that opened the source, or of a +/// confined caller asking now: a file outside them is not linked from the new document. +#[test] +fn a_file_outside_the_callers_roots_is_not_looked_for() { + let a = Folder::new("history-document-confined-a"); + let b = Folder::new("history-document-confined-b"); + let other = Folder::new("history-document-confined-other"); + let pic = a.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + place(&mut s, &pic); + s.execute("document.save", &json!({"path": b.file("poster.vectorcraft")})).unwrap(); + let moved = b.file("photo.png"); + std::fs::rename(&pic, &moved).unwrap(); + let narrow = crate::file_access::AutomationRoots::new(&[&other.0], &[]).unwrap().unwrap(); + let link = |s: &Session| { + let mut paths = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| paths.extend(im.link.as_ref().map(|l| l.path.clone()))); + paths + }; + crate::file_access::confine(Some(&narrow), || s.execute(CMD, &json!({}))).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + assert!(link(&s).iter().all(|p| *p != moved), "a confined caller: {:?}", link(&s)); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.active_mut().unwrap().opened_by = Some(narrow); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(2), "the second new document"); + let paths = link(&s); + assert!(!paths.is_empty() && paths.iter().all(|p| *p != moved), "opened by a confined caller: {paths:?}"); +} + +/// What reading the source's file left out still guards that file from the new document, and the +/// new document is read within what the caller that opened the source may read. +#[test] +fn the_new_document_keeps_what_guards_the_source() { + let dir = Folder::new("history-document-roots"); + let roots = crate::file_access::AutomationRoots::new(&[&dir.0], &[]).unwrap().unwrap(); + let mut s = session(); + let st = s.active_mut().unwrap(); + st.imported_from = Some("art.ai".into()); + st.import_losses = vec!["hidden text".into()]; + st.opened_by = Some(roots.clone()); + s.execute(CMD, &json!({})).unwrap(); + assert_eq!(s.active_index(), Some(1), "the new document"); + let st = s.doc().unwrap(); + assert_eq!((st.imported_from.as_deref(), st.import_losses.as_slice()), (Some("art.ai"), &["hidden text".to_string()][..])); + assert!(st.opened_by.as_ref().is_some_and(|r| Arc::ptr_eq(r, &roots))); +} + +/// The created date is the new document's own and joins the journal entry, so a replay gives the +/// same date. An action leaves it out. The new document has no modified date until it is saved. +#[test] +fn the_created_date_is_the_new_documents_own() { + let mut s = session(); + Arc::make_mut(&mut s.active_mut().unwrap().doc).metadata.modified = Some(500); + let source = s.doc().unwrap().doc.metadata.created; + s.execute(CMD, &json!({"created": 1000})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.created, Some(1000)); + assert_eq!(s.doc().unwrap().doc.metadata.modified, None, "the new document has not been saved"); + assert_eq!(s.documents()[0].doc.metadata.modified, Some(500)); + assert_eq!(s.documents()[0].doc.metadata.created, source); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute(CMD, &json!({"created": null})).unwrap(); + assert_eq!(s.doc().unwrap().doc.metadata.created, None); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + s.execute(CMD, &json!({})).unwrap(); + let noted = s.journal.last().unwrap().1["created"].as_i64(); + assert!(noted.is_some() && noted == s.doc().unwrap().doc.metadata.created, "{noted:?}"); + let mut action = s.journal.last().unwrap().1.clone(); + crate::command_sets::strip_replay_only(CMD, &mut action); + assert_eq!(action, json!({}), "an action leaves the date out"); +} diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index a4c222090..cfe43ee8e 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -244,6 +244,203 @@ fn the_relative_path_finds_the_file_after_the_folder_moves() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED)); } +/// Links in symbols, graph designs, patterns and opacity masks are saved with their relative +/// paths, as links in the layers are: after the folder moves, each file is found and its link takes +/// the new path, which the next save writes relative to the document again. +#[test] +fn links_outside_the_layers_find_their_files_after_the_folder_moves() { + let a = Folder::new("nested-move-a"); + let b = Folder::new("nested-move-b"); + let c = Folder::new("nested-move-c"); + let names = ["layer", "symbol", "design", "pattern", "mask"]; + for (i, name) in (0u8..).zip(names) { + write(&a.file(&format!("art/{name}.png")), &png(20, 10, [20 + 40 * i, 90, 160])); + } + let mut s = session(); + place(&mut s, &a.file("art/layer.png")); + let id = place(&mut s, &a.file("art/symbol.png")); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &a.file("art/design.png")); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let id = place(&mut s, &a.file("art/pattern.png")); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &a.file("art/mask.png")); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + let mut in_layers = vec![]; + s.doc().unwrap().doc.walk(|n| { + if let NodeKind::Image(im) = &n.kind { + in_layers.extend(im.link.as_ref().map(|l| l.name().to_string())); + } + }); + assert_eq!(in_layers, ["layer.png"], "only one file is shown by an object in the layers"); + // The links' paths, each image checked to show its file's pixels rather than the saved preview. + let paths = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + d.visit_images(|_, im| { + let l = im.link.as_ref().unwrap(); + assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name()); + out.push(PathBuf::from(&l.path)); + }); + out.sort(); + out + }; + let files = |dir: &Folder| { + let mut out: Vec = names.iter().map(|n| PathBuf::from(dir.file(&format!("art/{n}.png")))).collect(); + out.sort(); + out + }; + assert_eq!(paths(&s), files(&a)); + save(&mut s, &a.file("poster.vectorcraft")); + // Move the document with its links: the old folder is gone. + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + assert_eq!(paths(&s), files(&b), "the links follow the files"); + // Saved again and moved again. + save(&mut s, &b.file("poster.vectorcraft")); + std::fs::remove_dir_all(&c.0).unwrap(); + std::fs::rename(&b.0, &c.0).unwrap(); + let r = open(&mut s, &c.file("poster.vectorcraft")); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + assert_eq!(paths(&s), files(&c)); +} + +/// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved +/// with the document is read again for the layers' image and for the symbol's, and both links take +/// its new path, size and hash. +#[test] +fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { + let a = Folder::new("nested-modified-a"); + let b = Folder::new("nested-modified-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(20, 10, RED)); + let mut s = session(); + s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); + let id = place(&mut s, &pic); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &pic); + save(&mut s, &a.file("poster.vectorcraft")); + write(&pic, &png(30, 30, BLUE)); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}"); + let moved = PathBuf::from(b.file("art/photo.png")); + let blue = png(30, 30, BLUE); + let mut images = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| { + let l = im.link.as_ref().unwrap(); + images.push((PathBuf::from(&l.path), l.size, l.hash.clone(), im.width, im.height, im.key.clone())); + }); + let key = image(&s, id).key; + let new = (moved, Some(blue.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&blue)), 30, 30, key); + assert_eq!(images, [new.clone(), new], "the layers' image, then the symbol's"); +} + +/// A link inside a symbol that the document records without a relative path, beside a link in the +/// layers to the same file that has one, takes the file's new path after the folder moves. +#[test] +fn a_symbols_link_without_a_relative_path_follows_the_moved_file() { + let a = Folder::new("nested-unrelative-a"); + let b = Folder::new("nested-unrelative-b"); + let pic = a.file("art/photo.png"); + write(&pic, &png(20, 10, RED)); + let mut s = session(); + let id = place(&mut s, &pic); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + place(&mut s, &pic); + let doc_path = a.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // Removes the relative path of every link under `v` → how many it removed. + fn unrelative(v: &mut Value) -> usize { + match v { + Value::Object(o) => { + let own = match o.get_mut("link") { + Some(Value::Object(l)) => usize::from(l.remove("relative").is_some()), + _ => 0, + }; + own + o.values_mut().map(unrelative).sum::() + } + Value::Array(a) => a.iter_mut().map(unrelative).sum(), + _ => 0, + } + } + let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc_path).unwrap()).unwrap(); + assert_eq!(unrelative(&mut saved["document"]["symbols"]), 1, "the symbol's link"); + assert_eq!(unrelative(&mut saved["document"]["layers"].clone()), 1, "the layers' link keeps its relative path"); + std::fs::write(&doc_path, serde_json::to_vec(&saved).unwrap()).unwrap(); + std::fs::remove_dir_all(&b.0).unwrap(); + std::fs::rename(&a.0, &b.0).unwrap(); + let r = open(&mut s, &b.file("poster.vectorcraft")); + assert_eq!((r["missingLinks"].clone(), r["modifiedLinks"].clone()), (json!([]), json!([])), "{r}"); + let mut paths = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| paths.push(PathBuf::from(&im.link.as_ref().unwrap().path))); + let moved = PathBuf::from(b.file("art/photo.png")); + assert_eq!(paths, [moved.clone(), moved], "the layers' link, then the symbol's"); +} + +/// Update Links reads a changed file again for the images inside symbols, patterns and opacity +/// masks too: those showing the same content as an image in the layers, and those no object in the +/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step. +#[test] +fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { + let dir = Folder::new("nested-update"); + let (photo, logo) = (dir.file("photo.png"), dir.file("logo.png")); + write(&photo, &png(20, 10, RED)); + write(&logo, &png(20, 10, [200, 200, 20])); + let mut s = session(); + // photo.png in the layers, and in a symbol filling its bounds, clipped to them. + let id = place(&mut s, &photo); + s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &photo); + // logo.png only in a pattern and in an opacity mask. + let p = place(&mut s, &logo); + s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &logo); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + // photo.png wider than the symbol's image is: filling its bounds overflows them. + let (blue, green) = (png(60, 20, BLUE), png(40, 20, [20, 200, 20])); + write(&photo, &blue); + write(&logo, &green); + let u = s.execute("links.update", &json!({})).unwrap(); + assert_eq!(u, json!({"updated": [id.0], "missing": []})); + // (name, size, hash, pixel size) of each image, which shows its file's pixels. + let images = |s: &Session| { + let d = &s.doc().unwrap().doc; + let mut out = vec![]; + d.visit_images(|_, im| { + let l = im.link.as_ref().unwrap(); + assert!(!d.images[&im.key].is_proxy(), "{} shows its preview", l.name()); + out.push((l.name().to_string(), l.size, l.hash.clone(), (im.width, im.height))); + }); + out.sort(); + out + }; + let shown = |name: &str, bytes: &[u8], size| (name.to_string(), Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(bytes)), size); + let logo_now = shown("logo.png", &green, (40, 20)); + let photo_now = shown("photo.png", &blue, (60, 20)); + assert_eq!(images(&s), [logo_now.clone(), logo_now, photo_now.clone(), photo_now]); + // The symbol's image is clipped to its old bounds. + let mut clipped = false; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| { + if let NodeKind::Group { children, clip: true } = &n.kind { + clipped |= + matches!(&children[..], [c, im] if matches!(c.kind, NodeKind::Path { clipping: true, .. }) && matches!(im.kind, NodeKind::Image(_))); + } + }); + assert!(clipped, "{:?}", s.doc().unwrap().doc.symbols[0].art); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s)); +} + #[test] fn a_missing_link_renders_its_preview_and_relinks() { let dir = Folder::new("missing"); @@ -283,6 +480,151 @@ fn a_missing_link_renders_its_preview_and_relinks() { assert!(s.execute("links.relink", &json!({"ids": [id.0]})).is_err(), "path or folder"); } +/// The path of the file image `id` links to. +fn link_path(s: &Session, id: NodeId) -> String { + image(s, id).link.map(|l| l.path).unwrap_or_default() +} + +/// Replace: All Instances relinks every image linked to the same file as the one given, in one +/// undo step; Selected Instances (the default) only the one given. +#[test] +fn relink_all_instances_takes_every_image_of_the_same_file() { + let dir = Folder::new("relink-all"); + let (pic, other) = (dir.file("photo.png"), dir.file("other.png")); + write(&pic, &png(60, 30, RED)); + write(&other, &png(60, 30, RED)); + let mut s = session(); + let (a, b, c) = (place(&mut s, &pic), place(&mut s, &pic), place(&mut s, &other)); + let blue = dir.file("new/blue.png"); + write(&blue, &png(60, 30, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "selected"})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0], "notFound": []})); + assert_eq!(link_path(&s, b), pic, "selected instances: the other copy keeps its file"); + s.execute("edit.undo", &json!({})).unwrap(); + let r = s.execute("links.relink", &json!({"ids": [a.0], "path": blue, "scope": "all"})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0, b.0], "notFound": []})); + assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (blue.clone(), blue.clone(), other.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((link_path(&s, a), link_path(&s, b)), (pic.clone(), pic.clone()), "one undo step"); + // The selection stands in for ids. + s.execute("select.set", &json!({"ids": [b.0]})).unwrap(); + let r = s.execute("links.relink", &json!({"path": blue, "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([b.0, a.0])); +} + +/// Replace: Missing Links from This Folder relinks the given links to the chosen file, then every +/// other missing link to the file of its name in that file's folder, in one undo step; names not +/// there are listed in notFound and stay missing. +#[test] +fn relink_missing_links_from_this_folder_finds_the_others_by_name() { + let dir = Folder::new("relink-missing-folder"); + let names = ["a.png", "b.png", "c.png", "d.png"]; + let mut s = session(); + let ids: Vec = names + .iter() + .map(|n| { + write(&dir.file(n), &png(60, 30, RED)); + place(&mut s, &dir.file(n)) + }) + .collect(); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + // d.png stays where it was: it isn't missing. + for n in &names[..3] { + std::fs::remove_file(dir.file(n)).unwrap(); + } + open(&mut s, &doc_path); + let found = dir.file("found/a new name.png"); + write(&found, &png(60, 30, BLUE)); + write(&dir.file("found/b.png"), &png(60, 30, BLUE)); + write(&dir.file("found/d.png"), &png(60, 30, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [ids[0].0], "path": found, "scope": "missingInFolder"})).unwrap(); + assert_eq!(r, json!({"relinked": [ids[0].0, ids[1].0], "notFound": ["c.png"]})); + let b_found = dir.0.join("found").join("b.png").to_string_lossy().into_owned(); + assert_eq!((link_path(&s, ids[0]), link_path(&s, ids[1])), (found.clone(), b_found)); + assert_eq!((link_path(&s, ids[2]), link_path(&s, ids[3])), (dir.file("c.png"), dir.file("d.png"))); + let c = s.execute("links.check", &json!({})).unwrap(); + assert_eq!(c["missing"], 1, "{c}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["missing"], 3, "one undo step"); +} + +/// Replace: Each Link Separately takes one file for each link, in one undo step. +#[test] +fn relink_each_link_separately_takes_a_file_for_each() { + let dir = Folder::new("relink-each"); + let (one, two) = (dir.file("one.png"), dir.file("two.png")); + write(&one, &png(60, 30, RED)); + write(&two, &png(60, 30, RED)); + let mut s = session(); + let (a, b, c) = (place(&mut s, &one), place(&mut s, &one), place(&mut s, &two)); + let (x, y) = (dir.file("new/x.png"), dir.file("new/y.png")); + write(&x, &png(60, 30, BLUE)); + write(&y, &png(30, 30, BLUE)); + let paths = json!([{"ids": [a.0, b.0], "path": x}, {"ids": [c.0], "path": y}]); + let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap(); + assert_eq!(r, json!({"relinked": [a.0, b.0, c.0], "notFound": []})); + assert_eq!((link_path(&s, a), link_path(&s, b), link_path(&s, c)), (x.clone(), x.clone(), y.clone())); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!((link_path(&s, a), link_path(&s, c)), (one.clone(), two.clone()), "one undo step"); + // Two entries naming the same file read it once. + let paths = json!([{"ids": [a.0], "path": y}, {"ids": [c.0], "path": y}]); + let r = s.execute("links.relink", &json!({"scope": "each", "paths": paths})).unwrap(); + assert_eq!(r["relinked"], json!([a.0, c.0])); +} + +/// With copy, a file that can't be shown stops the call before any file is copied into the +/// document's folder. +#[test] +fn relink_each_with_copy_copies_nothing_when_a_file_cant_be_read() { + let dir = Folder::new("relink-each-copy"); + let (one, two) = (dir.file("doc/one.png"), dir.file("doc/two.png")); + write(&one, &png(60, 30, RED)); + write(&two, &png(60, 30, RED)); + let mut s = session(); + let (a, c) = (place(&mut s, &one), place(&mut s, &two)); + save(&mut s, &dir.file("doc/poster.vectorcraft")); + let (x, bad) = (dir.file("new/x.png"), dir.file("new/broken.png")); + write(&x, &png(60, 30, BLUE)); + write(&bad, b"not an image"); + let paths = json!([{"ids": [a.0], "path": x}, {"ids": [c.0], "path": bad}]); + assert!(s.execute("links.relink", &json!({"scope": "each", "paths": paths, "copy": true})).is_err()); + assert!(!Path::new(&dir.file("doc/x.png")).exists(), "no copy is left behind"); + assert_eq!(link_path(&s, a), one); +} + +/// A scope or paths that doesn't fit is refused, and nothing is relinked. +#[test] +fn relink_refuses_scopes_and_paths_that_dont_fit() { + let dir = Folder::new("relink-bad-scope"); + let pic = dir.file("photo.png"); + write(&pic, &png(60, 30, RED)); + let mut s = session(); + let a = place(&mut s, &pic); + let blue = dir.file("blue.png"); + write(&blue, &png(60, 30, BLUE)); + let bad = [ + json!({"ids": [a.0], "path": blue, "scope": "everything"}), + json!({"ids": [a.0], "path": blue, "scope": 3}), + json!({"scope": "each"}), + json!({"scope": "each", "paths": []}), + json!({"scope": "each", "paths": [{"ids": [a.0]}]}), + json!({"scope": "each", "paths": [{"path": blue}]}), + json!({"scope": "each", "paths": [{"ids": ["one"], "path": blue}]}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}, {"ids": [a.0], "path": pic}]}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "path": blue}), + json!({"scope": "each", "paths": [{"ids": [a.0], "path": blue}], "ids": [a.0]}), + json!({"scope": "all", "paths": [{"ids": [a.0], "path": blue}]}), + json!({"ids": [a.0], "paths": [{"ids": [a.0], "path": blue}]}), + json!({"ids": [a.0], "folder": dir.0.to_string_lossy(), "scope": "missingInFolder"}), + json!({"ids": [a.0], "scope": "missingInFolder"}), + ]; + for p in bad { + assert!(s.execute("links.relink", &p).is_err(), "{p}"); + assert_eq!(link_path(&s, a), pic, "{p} relinks nothing"); + } +} + /// A link stored as a classic Mac OS path (`Disk:Folder:file`, as older `.ai` files keep them) is /// named by its last component: the Links panel shows that name and its folder, Relink to Folder /// looks for that name, and the document's own folder is searched for it. The stored path is kept. @@ -452,6 +794,63 @@ fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() { assert!(near(centre_colour(&s.doc().unwrap().doc), RED), "canvas still shows the preview"); } +/// An SVG linking two missing files: once one of them is found, export reads its pixels into its +/// own images only, and the other file's image keeps its placeholder. +#[test] +fn export_reads_a_found_svg_link_into_its_own_images_only() { + let dir = Folder::new("svg-missing-keys"); + let svg = r#""#; + let svg_path = dir.file("art.svg"); + write(&svg_path, svg.as_bytes()); + let mut s = session(); + open(&mut s, &svg_path); + let mut found = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone())); + let [a, b] = &found[..] else { panic!("two images: {found:?}") }; + assert!(a.link.as_ref().unwrap().path.ends_with("a.png") && b.link.as_ref().unwrap().path.ends_with("b.png")); + let placeholder = s.doc().unwrap().doc.images[&b.key].bytes.clone(); + + let bytes = png(20, 20, RED); + write(&dir.file("a.png"), &bytes); + let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None); + assert_eq!(warnings.len(), 1, "b.png is still missing: {warnings:?}"); + assert_eq!(*full.images[&a.key].bytes, bytes, "a.png's pixels"); + assert_eq!(full.images[&b.key].bytes, placeholder, "b.png keeps its placeholder"); +} + +/// An SVG linking two files with the same pixels, saved with previews only: once one file is gone +/// and the other has changed, export reads the changed file into its own images only. +#[test] +fn export_reads_a_changed_svg_link_into_its_own_images_only() { + let dir = Folder::new("svg-same-pixels"); + let svg = r#""#; + let svg_path = dir.file("art.svg"); + write(&svg_path, svg.as_bytes()); + write(&dir.file("a.png"), &png(600, 300, RED)); + write(&dir.file("b.png"), &png(600, 300, RED)); + let mut s = session(); + open(&mut s, &svg_path); + let doc_path = dir.file("art.vectorcraft"); + save(&mut s, &doc_path); + std::fs::remove_file(dir.file("a.png")).unwrap(); + let blue = png(600, 300, BLUE); + write(&dir.file("b.png"), &blue); + + open(&mut s, &doc_path); + let mut found = vec![]; + s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone())); + let [a, b] = &found[..] else { panic!("two images: {found:?}") }; + assert!(a.link.as_ref().unwrap().path.ends_with("a.png") && b.link.as_ref().unwrap().path.ends_with("b.png")); + assert_ne!(a.key, b.key, "two files, two keys"); + let doc = &s.doc().unwrap().doc; + assert!(doc.images[&a.key].is_proxy() && doc.images[&b.key].is_proxy(), "previews"); + let preview = doc.images[&a.key].bytes.clone(); + let (full, warnings) = cmd::links::full_linked_images(doc, None); + assert_eq!(warnings.len(), 1, "a.png is missing: {warnings:?}"); + assert_eq!(*full.images[&b.key].bytes, blue, "b.png's new pixels"); + assert_eq!(full.images[&a.key].bytes, preview, "a.png keeps its preview"); +} + /// The half of opening that the desktop app runs on a thread of its own (`read_for_open`) reads /// the linked files and lays out the type. The half left for the UI thread (`open_read`) reads no /// file: with a linked file moved away in between, it opens the document that `document.open` opens. diff --git a/crates/engine/src/tests/live.rs b/crates/engine/src/tests/live.rs index 1dc5f66ba..c7479ce8f 100644 --- a/crates/engine/src/tests/live.rs +++ b/crates/engine/src/tests/live.rs @@ -2,7 +2,7 @@ use serde_json::{Value, json}; use vectorcraft_color::{Color, Paint}; -use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind}; +use vectorcraft_doc::live::{self, BlendOrientation, BlendSpacing, EnvelopeKind, PreserveShape}; use vectorcraft_doc::{Node, NodeKind}; use vectorcraft_geom::{Point, Rect}; @@ -87,6 +87,12 @@ fn refuses_non_boolean(s: &mut Session, cmd: &str, p: &Value, key: &str) { assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); } +/// `cmd` with `p` fails with the error `want`. +fn refuses_with(s: &mut Session, cmd: &str, p: &Value, want: &str) { + let r = s.execute(cmd, p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{cmd} {p}: {r:?}"); +} + // ---------- Blend ---------- #[test] @@ -231,13 +237,13 @@ fn blend_smooth_colour_steps_from_colour_distance() { let mut s = session(); let (_, _, g) = blend(&mut s, json!({})); let n = steps_of(&s, g).len() - 2; - assert_eq!(n, 128, "black→white: one step per two levels"); - // Consecutive steps differ by at most ~2 levels. + assert_eq!(n, 254, "black→white: one step per level"); + // Consecutive steps differ by about a level. let st = steps_of(&s, g); for w in st.windows(2) { let x = w[0].appearance.fill_paint().color().unwrap().to_rgb()[0]; let y = w[1].appearance.fill_paint().color().unwrap().to_rgb()[0]; - assert!((x - y).abs() <= 2.5 / 255.0); + assert!((x - y).abs() <= 1.5 / 255.0); } } @@ -248,7 +254,9 @@ fn blend_smooth_colour_same_colours_uses_distance() { let b = rect(&mut s, 40.0, 0.0, 10.0, 10.0); sel(&mut s, &[a, b]); let g = id_of(&s.execute("object.blend.make", &json!({"smooth": true})).unwrap()); - assert_eq!(steps_of(&s, g).len() - 2, 20); + // The edges are 40 pt apart, 3.6 times the keys' 11 pt visible width (their 1 pt strokes + // included): rounded, less one. + assert_eq!(steps_of(&s, g).len() - 2, 3); } #[test] @@ -745,6 +753,73 @@ fn envelope_options_refuse_a_boolean_given_as_text() { assert!(matches!(&node(&s, warp).kind, NodeKind::Envelope { options, .. } if !options.anti_alias && options.distort_appearance)); } +/// Make with Warp, Reset with Warp and Envelope Options refuse a warp `style` that is not text or +/// names no warp, and an `orientation` other than horizontal or vertical: no envelope is made, a +/// warp envelope keeps its warp, and no undo step is added. Null is read as the key left out. +#[test] +fn envelope_warps_refuse_an_invalid_style_or_orientation() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let n = undo_len(&s); + let calls = [ + (json!({"style": 5}), "`style` must be a string"), + (json!({"style": ["arch"], "bend": 40}), "`style` must be a string"), + (json!({"style": "foo"}), "unknown warp style `foo`"), + (json!({"orientation": 1}), "`orientation` must be one of horizontal, vertical"), + (json!({"orientation": true, "horizontal": true}), "`orientation` must be one of horizontal, vertical"), + (json!({"orientation": "diagonal"}), "`orientation` must be one of horizontal, vertical"), + ]; + for (p, want) in &calls { + refuses_with(&mut s, "object.envelope.makeWithWarp", p, want); + } + assert_eq!(undo_len(&s), n, "nothing changed"); + assert!(matches!(node(&s, a).kind, NodeKind::Path { .. }), "not enveloped"); + let e = id_of(&s.execute("object.envelope.makeWithWarp", &json!({"style": null, "orientation": null, "bend": 30})).unwrap()); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } + if matches!(&**kind, EnvelopeKind::Warp { style, horizontal: true, .. } if style == "arc"))); + let (before, n) = (node(&s, e), undo_len(&s)); + for cmd in ["object.envelope.resetWithWarp", "object.envelope.options"] { + for (p, want) in &calls { + refuses_with(&mut s, cmd, p, want); + } + } + assert_eq!((node(&s, e), undo_len(&s)), (before, n), "nothing changed"); + s.execute("select.none", &json!({})).unwrap(); + for (p, want) in &calls { + refuses_with(&mut s, "object.envelope.options", p, want); + } + sel(&mut s, &[e]); + s.execute("object.envelope.resetWithWarp", &json!({"style": "warp.flag", "orientation": "vertical"})).unwrap(); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { kind, .. } + if matches!(&**kind, EnvelopeKind::Warp { style, horizontal: false, .. } if style == "flag"))); +} + +/// Envelope Options refuses a `preserveShape` that is not text, for the selected envelope and for +/// the options new envelopes get, and changes neither; null keeps the current value. +#[test] +fn envelope_options_refuse_a_preserve_shape_that_is_not_text() { + let mut s = session(); + let a = text_rect(&mut s); + sel(&mut s, &[a]); + let e = id_of(&s.execute("object.envelope.makeWithMesh", &json!({"rows": 2, "cols": 2})).unwrap()); + let calls = [json!({"preserveShape": 1}), json!({"preserveShape": true, "antiAlias": false}), json!({"preserveShape": ["transparency"]})]; + for ids in [vec![e], vec![]] { + sel(&mut s, &ids); + let before = (node(&s, e), undo_len(&s)); + for p in &calls { + refuses_with(&mut s, "object.envelope.options", p, "`preserveShape` must be a string"); + } + assert_eq!((node(&s, e), undo_len(&s)), before, "nothing changed with {ids:?} selected"); + } + assert_eq!(s.envelope_defaults, None, "the options new envelopes get stayed"); + sel(&mut s, &[e]); + s.execute("object.envelope.options", &json!({"preserveShape": "transparency"})).unwrap(); + s.execute("object.envelope.options", &json!({"preserveShape": null, "antiAlias": false})).unwrap(); + assert!(matches!(&node(&s, e).kind, NodeKind::Envelope { options, .. } + if options.preserve_shape == PreserveShape::Transparency && !options.anti_alias)); +} + #[test] fn envelope_renders_distorted() { let mut s = session(); diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index 7d406a0c3..4e6100df9 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -2087,6 +2087,63 @@ fn find_next_cycles_through_text_objects() { assert!(s.execute("edit.findNext", &json!({"find": "zzz"})).unwrap()["id"].is_null()); } +/// The Type menu's commands and Find and Replace and Find Next refuse a param of the wrong type, or +/// `ids` that name no object, with an error naming the param, and leave the text, the selection and +/// the undo history as they were, even when Insert Character is also given a source it would use +/// first. Null counts as a param left out, which keeps its default. +#[test] +fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() { + let mut s = session(); + let t = text(&mut s, "\"cat\" -- cat catalog..."); + let pid = s.execute("path.create", &json!({"d": "M100 300 L500 300"})).unwrap()["id"].as_u64().unwrap(); + let r = s.execute("text.createInPath", &json!({"path": pid, "mode": "onPath", "text": "cat", "size": 12, "at": [300, 310]})).unwrap(); + let on_path = id_of(&r); + select_with_a_step_to_redo(&mut s, &[t, on_path]); + let before = (history(&s), plain(&s, t), plain(&s, on_path), selected(&s)); + let string = |k: &str| format!("`{k}` must be a string"); + let switch = |k: &str| format!("`{k}` must be true or false"); + let scope = "`scope` must be one of selection, document".to_string(); + let not_a_list = "ids must be an array of non-negative integer object ids".to_string(); + let not_an_id = "invalid object id \"x\": expected a non-negative integer".to_string(); + for (cmd, p, want) in [ + ("edit.findReplace", json!({"find": "cat", "replace": 5}), string("replace")), + ("edit.findReplace", json!({"find": 5, "replace": "dog"}), string("find")), + ("edit.findReplace", json!({"find": "CAT", "replace": "dog", "matchCase": "true"}), switch("matchCase")), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "wholeWord": "yes"}), switch("wholeWord")), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": t.0}), not_a_list), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, 999_999]}), "no such object id 999999".to_string()), + ("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id), + ("edit.findNext", json!({"find": ["cat"]}), string("find")), + ("edit.findNext", json!({"find": "cat", "matchCase": 1}), switch("matchCase")), + ("edit.findNext", json!({"find": "cat", "wholeWord": "false"}), switch("wholeWord")), + ("type.smartPunctuation", json!({"dashes": "false"}), switch("dashes")), + ("type.smartPunctuation", json!({"ellipsis": 0}), switch("ellipsis")), + ("type.smartPunctuation", json!({"quotes": "no"}), switch("quotes")), + ("type.smartPunctuation", json!({"scope": 5}), scope.clone()), + ("type.smartPunctuation", json!({"scope": "page"}), scope), + ("type.changeCase", json!({"case": 5}), string("case")), + ("type.insert", json!({"char": 5}), string("char")), + ("type.insert", json!({"text": 5}), string("text")), + ("type.insert", json!({"unicode": 176}), string("unicode")), + ("type.insert", json!({"char": "bullet", "unicode": 176}), string("unicode")), + ("type.insert", json!({"text": "x", "code": "a"}), "`code` must be a whole number".to_string()), + ("type.insert", json!({"text": "x", "code": 1.5}), "`code` must be a whole number".to_string()), + ("type.pathOptions", json!({"flip": "true"}), switch("flip")), + ("type.pathOptions", json!({"effect": 1}), string("effect")), + ("type.pathOptions", json!({"alignToPath": true}), string("alignToPath")), + ("type.pathOptions", json!({"spacing": "4"}), "`spacing` must be a number, not \"4\"".to_string()), + ] { + let r = s.execute(cmd, &p); + assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if *msg == want), "{cmd} {p}: {r:?}"); + assert_eq!((history(&s), plain(&s, t), plain(&s, on_path), selected(&s)), before, "{cmd} {p}"); + } + // `code: null` gives way to `unicode`, and null switches and ids take their defaults. + s.execute("type.insert", &json!({"code": null, "unicode": "00B0", "ids": [on_path.0]})).unwrap(); + assert_eq!(plain(&s, on_path), "cat°"); + let r = s.execute("edit.findReplace", &json!({"find": "cat", "replace": "dog", "matchCase": null, "wholeWord": null, "ids": null})).unwrap(); + assert_eq!((r["count"].as_u64(), plain(&s, t), plain(&s, on_path)), (Some(4), "\"dog\" -- dog dogalog...".to_string(), "dog°".to_string())); +} + // ---------- Select ---------- #[test] diff --git a/crates/engine/src/tests/paintproxy.rs b/crates/engine/src/tests/paintproxy.rs index b64736b97..b270826e7 100644 --- a/crates/engine/src/tests/paintproxy.rs +++ b/crates/engine/src/tests/paintproxy.rs @@ -185,16 +185,53 @@ fn recent_colors_are_fed_by_every_paint_command() { s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap(); assert_eq!(hexes(&s).len(), 3); // The list holds the newest `RECENT_MAX` colours. - for i in 0..20u8 { + for i in 0..40u8 { s.execute("paint.setFill", &json!({"color": Color::rgb8(i, 0, 0).to_hex()})).unwrap(); } assert_eq!(s.recent_colors.len(), Session::RECENT_MAX); - assert_eq!(s.recent_colors[0].to_hex(), "#130000"); + assert_eq!(s.recent_colors[0].to_hex(), "#270000"); let r = s.execute("paint.recent", &json!({})).unwrap(); - assert_eq!(r["colors"][0]["hex"], "#130000"); + assert_eq!(r["colors"][0]["hex"], "#270000"); let _ = id; } +#[test] +fn recent_colors_keep_thirty_colors_across_documents() { + let mut s = session(); + rect(&mut s); + let red = |i: u8| Color::rgb8(i, 0, 0).to_hex(); + for i in 1..=30u8 { + s.execute("paint.setFill", &json!({"color": red(i)})).unwrap(); + } + assert_eq!(Session::RECENT_MAX, 30); + let all: Vec = (1..=30u8).rev().map(red).collect(); + assert_eq!(hexes(&s), all, "30 colors are kept, newest first"); + // The 31st drops the oldest. + s.execute("paint.setFill", &json!({"color": red(31)})).unwrap(); + assert_eq!(hexes(&s).len(), 30); + assert_eq!(hexes(&s)[0], red(31)); + assert_eq!(hexes(&s)[29], red(2)); + assert!(!hexes(&s).contains(&red(1)), "the oldest color is dropped"); + // Applying a color already in the list moves it to the front, once. + s.execute("paint.setStroke", &json!({"color": red(10)})).unwrap(); + assert_eq!(hexes(&s).len(), 30); + assert_eq!(hexes(&s)[0], red(10)); + assert_eq!(hexes(&s).iter().filter(|h| **h == red(10)).count(), 1); + let before = hexes(&s); + // A new document, and going back to the first one, keep the list. + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + assert_eq!(hexes(&s), before); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(hexes(&s), before); + // A color applied in the second document joins the same list. + s.execute("document.activate", &json!({"index": 1})).unwrap(); + rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + s.execute("document.activate", &json!({"index": 0})).unwrap(); + assert_eq!(hexes(&s)[0], "#00ff00"); + assert_eq!(hexes(&s)[1..], before[..29]); +} + #[test] fn live_previews_remember_only_the_committed_colour() { let mut s = session(); diff --git a/crates/engine/src/tests/placed_document.rs b/crates/engine/src/tests/placed_document.rs index 743f81494..7616a4529 100644 --- a/crates/engine/src/tests/placed_document.rs +++ b/crates/engine/src/tests/placed_document.rs @@ -488,6 +488,35 @@ fn relink_points_a_placed_document_at_another_file() { assert!(near(centre_colour(&s.doc().unwrap().doc), BLUE)); } +/// Missing Links from This Folder finds a missing placed document by name, except with copy, which +/// takes images only: the placed document then stays as it is. +#[test] +fn relink_missing_in_folder_finds_placed_documents_except_with_copy() { + let dir = Folder::new("placed-missing-folder"); + let (doc_file, pic) = (dir.file("art/a.vectorcraft"), dir.file("art/photo.png")); + write(&pic, &crate::tests_links::png(60, 30, RED)); + source(&doc_file, 100.0, 50.0, RED, 0); + let mut s = session(); + let (placed_id, image_id) = (place(&mut s, &doc_file), place(&mut s, &pic)); + let doc_path = dir.file("poster.vectorcraft"); + save(&mut s, &doc_path); + std::fs::remove_file(&doc_file).unwrap(); + std::fs::remove_file(&pic).unwrap(); + open(&mut s, &doc_path); + let found = dir.0.join("found"); + let chosen = found.join("renamed.png").to_string_lossy().into_owned(); + write(&chosen, &crate::tests_links::png(60, 30, BLUE)); + source(&found.join("a.vectorcraft").to_string_lossy(), 100.0, 50.0, BLUE, 0); + let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder", "copy": true}); + let r = s.execute("links.relink", &p).unwrap(); + assert_eq!(r["relinked"], json!([image_id.0]), "{r}"); + assert_eq!(placed(&s, placed_id).link.path, doc_file, "copy leaves the placed document"); + s.execute("edit.undo", &json!({})).unwrap(); + let p = json!({"ids": [image_id.0], "path": chosen, "scope": "missingInFolder"}); + let r = s.execute("links.relink", &p).unwrap(); + assert_eq!(r["relinked"], json!([image_id.0, placed_id.0]), "{r}"); +} + /// A file made from scratch at `path` in `format` (`pdf`, or `ai`: a PDF-compatible .ai): one /// 100×50 page per color of `pages`, filled with it (`extra` more rectangles on each change the /// file's size). @@ -891,6 +920,36 @@ fn saving_a_document_refreshes_the_open_documents_that_place_it() { assert_eq!(s.active_index(), Some(child), "the saved document stays active"); } +/// Placed documents inside a symbol and a pattern of another open document are read again when +/// their file is saved, as one in the layers is. +#[test] +fn saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns() { + let dir = Folder::new("placed-refresh-nested"); + let src = dir.file("logo.vectorcraft"); + source(&src, 100.0, 50.0, RED, 0); + let mut s = session(); + let id = place(&mut s, &src); + s.execute("symbol.new", &json!({"name": "Logo", "ids": [id.0]})).unwrap(); + let id = place(&mut s, &src); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let parent = s.active_index().unwrap(); + let mut in_layers = 0; + s.doc().unwrap().doc.walk(|n| in_layers += usize::from(matches!(n.kind, NodeKind::PlacedDocument(_)))); + assert_eq!(in_layers, 0, "no placed document in the layers"); + s.execute("document.open", &json!({"path": src})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#1414e6"})).unwrap(); + s.execute("document.save", &json!({})).unwrap(); + let hash = Some(vectorcraft_doc::links::hash_bytes(&std::fs::read(&src).unwrap())); + let doc = &s.documents()[parent]; + assert_eq!(doc.history.undo.last().unwrap().label, "Update Links"); + assert!(near(centre_colour(&doc.doc), BLUE), "the symbol's instance shows the saved version"); + let mut hashes = vec![]; + doc.doc.visit_placed(|_, p| hashes.push(p.link.hash.clone())); + assert_eq!(hashes, [hash.clone(), hash], "the symbol's, then the pattern's"); +} + #[test] fn link_info_gives_the_artboards_size_not_pixels() { let dir = Folder::new("placed-info"); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 36694ef87..fdfe60067 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -747,6 +747,77 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { run(&mut granted, "plugin.remove", json!({"id": id})); } +/// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is +/// skipped without dropping the other grants, and recording a grant writes it back as it was, +/// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a +/// list is never overwritten. +#[test] +fn grants_this_version_cant_read_are_kept() { + let folder = crate::tests_links::Folder::new("plugin-store-grants-unknown"); + let kept = "test.engine.permissions.kept"; + let added = "test.engine.permissions.added"; + let later = "test.engine.permissions.later"; + let empty = json!({"objects": []}); + let kept_wasm = asking(kept, r#"["doc.read"]"#, &empty); + let added_wasm = asking(added, r#"["doc.read"]"#, &empty); + let later_wasm = asking(later, r#"["doc.read"]"#, &empty); + let future = json!({"id": later, "hash": "00", "permissions": ["doc.read", "ui.panel"]}); + let invalid = json!({"id": "bad id/", "hash": "00", "permissions": ["doc.read"]}); + let grant = json!({"id": kept, "hash": vectorcraft_plugins::permissions::sha256_hex(&kept_wasm), "permissions": ["doc.read"]}); + let grants = folder.0.join("grants.json"); + std::fs::write(&grants, serde_json::to_vec(&json!([future, grant, invalid])).unwrap()).unwrap(); + std::fs::write(folder.0.join("kept.wasm"), &kept_wasm).unwrap(); + let entries = || serde_json::from_slice::>(&std::fs::read(&grants).unwrap()).unwrap(); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + assert!(vectorcraft_plugins::registry::get(kept).is_some(), "the entries this version can't read don't drop the grant next to them"); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + let now = entries(); + assert_eq!(now.len(), 4, "{now:?}"); + assert!(now.contains(&future) && now.contains(&invalid), "written back as they were: {now:?}"); + assert!(now.iter().any(|e| e["id"] == added), "{now:?}"); + run(&mut s, "plugin.remove", json!({"id": added})); + assert_eq!(entries().len(), 3); + // Granting the plug-in an entry names replaces that entry. + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&later_wasm), "grant": ["doc.read"]})); + let now = entries(); + assert!(!now.contains(&future), "{now:?}"); + assert_eq!(now.iter().filter(|e| e["id"] == later).count(), 1, "{now:?}"); + run(&mut s, "plugin.remove", json!({"id": later})); + run(&mut s, "plugin.remove", json!({"id": kept})); + assert_eq!(entries(), vec![invalid.clone()], "the last usable grant removed leaves the entry it can't read"); + + // A file that doesn't hold a list is left as it is: installing still works in this session. + for broken in [&b"[{\"id\": "[..], &b"{\"id\": \"x\"}"[..]] { + std::fs::write(&grants, broken).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + assert!(vectorcraft_plugins::registry::get(added).is_some()); + assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten"); + run(&mut s, "plugin.remove", json!({"id": added})); + assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten by a removal either"); + } + + // The file stays within the 1 MiB it is read up to: compact when pretty-printing would pass it, + // and left as it is when even compact would. + let cap = 1 << 20; + let many: Vec = (0..14_000).map(|i| json!({"id": format!("x.{i}"), "hash": "00", "permissions": ["ui.panel"]})).collect(); + let compact = serde_json::to_vec(&many).unwrap(); + assert!(compact.len() < cap && serde_json::to_vec_pretty(&many).unwrap().len() > cap); + std::fs::write(&grants, &compact).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + let written = std::fs::read(&grants).unwrap(); + assert!(written.len() <= cap, "{}", written.len()); + assert_eq!(entries().len(), many.len() + 1); + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm)})); + assert_eq!(inspected["needsApproval"], json!(false), "the compact file is read back"); + run(&mut s, "plugin.remove", json!({"id": added})); + let full = serde_json::to_vec(&json!([{"id": "x.full", "hash": "0".repeat(cap - 100), "permissions": ["ui.panel"]}])).unwrap(); + std::fs::write(&grants, &full).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]})); + assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap"); + run(&mut s, "plugin.remove", json!({"id": added})); +} + /// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next /// start without asking, with its budget. #[test] @@ -840,6 +911,35 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() { assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects"); } +/// A plug-in of API level 2 that uses the host imports lists `progress`, which the user grants like +/// any permission; a filter run made elsewhere then reports its progress, as a share, to the +/// progress the caller shows, and sees the caller's Cancel. +#[test] +fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() { + use crate::cmd::fileio::progress::{self, Progress}; + use crate::cmd::plugin::FilterRun; + let mut s = session(); + let id = "test.engine.level2"; + let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}}"#); + let wasm = wat::parse_str(tpl::cooperating(&manifest, 1000, r#"{"objects":[]}"#, r#"{"objects":[]}"#)).unwrap(); + let data = vectorcraft_format::base64_encode(&wasm); + let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data})); + assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read", "doc.write", "progress"]))); + let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})).unwrap_err().to_string(); + assert!(e.contains("asks for the permissions doc.read, doc.write, progress"), "{e}"); + run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write", "progress"]})); + let shown = Progress::new(); + let r = FilterRun::prepare(&s, &json!({"id": id})).unwrap(); + assert!(progress::scope(&shown, || r.run()).is_ok()); + assert_eq!((shown.counts(), shown.counts_share()), ((999, 1000), true)); + // Cancelled, it stops on its first step, and what it returns then is discarded. + shown.cancel(); + let cancelled = progress::scope(&shown, || r.run()); + assert!(matches!(cancelled, Err(EngineError::Cancelled)), "{cancelled:?}"); + assert_eq!(shown.counts(), (0, 1000)); + run(&mut s, "plugin.remove", json!({"id": id})); +} + /// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between /// two slices of its budget and changes nothing. #[test] diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index 4b5848bcf..437701136 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -814,6 +814,37 @@ fn invoke_distance_guides_measures_the_selection_to_the_object_under_alt() { assert!(shown(&mut s, v).is_empty(), "Invoke Distance Guides off"); } +/// Smart Guides › Snap to Last Location (off by default): on, a square dragged 6 pt right and 5 pt +/// down with a 10 px Snapping Tolerance goes back to where it was; dragged 12 pt away, or with the +/// option or View › Smart Guides off, it lands where it is dragged. A value that is not a boolean +/// is refused. +#[test] +fn snap_to_last_location_puts_a_dragged_square_back() { + use vectorcraft_tools::PointerKind::{Down, Drag, Up}; + let mut s = new_doc(); + // Its edges and center stay out of reach of the 400 pt artboard's. + let m = square(&mut s, 100.0, 250.0); + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + let drag = |s: &mut Session, v: ViewInfo, to: (f64, f64)| { + gesture(s, v, &[(Down, 125.0, 275.0), (Drag, 140.0, 290.0), (Drag, to.0, to.1), (Up, to.0, to.1)]); + let at = top_left(s, m); + if at != (100.0, 250.0) { + s.execute("edit.undo", &json!({})).unwrap(); + } + at + }; + set_pref(&mut s, "snappingTolerance", json!(10)); + assert_eq!(drag(&mut s, v, (131.0, 280.0)), (106.0, 255.0), "off by default"); + set_pref(&mut s, "snapToLastLocation", json!(true)); + assert_eq!(drag(&mut s, v, (131.0, 280.0)), (100.0, 250.0), "7.8 pt away: back where it was"); + assert_eq!(drag(&mut s, v, (134.0, 283.0)), (109.0, 258.0), "12 pt away"); + assert_eq!(drag(&mut s, ViewInfo { smart_guides: false, ..v }, (131.0, 280.0)), (106.0, 255.0), "View › Smart Guides off"); + let refused = s.execute("prefs.set", &json!({"key": "snapToLastLocation", "value": "yes"})); + assert!(refused.is_err(), "{refused:?}"); + assert_eq!(s.execute("prefs.get", &json!({"key": "snapToLastLocation"})).unwrap(), json!(true), "unchanged"); +} + /// Preferences › Smart Guides (#394): Color, Alignment Guides, Anchor/Path Labels, Measurement /// Labels, Transform Tools and Snapping Tolerance change what the Selection tool shows while a /// square is dragged into line with another, and how far the pull reaches; a hidden guide still @@ -1936,6 +1967,315 @@ fn real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline() { assert!(moved.0 > 1.0 && moved.1 > 1.0 && moved.2.abs() < 1e-6 && moved.3.abs() < 1e-6, "the bounds moved with the drag: {moved:?}"); } +/// The bounding boxes (left, top, width) of the Real-time Drawing off feedback after a drag by (30, 40) +/// from (x, y), left to right. +fn dragged_feedback_boxes(s: &mut Session, x: f64, y: f64) -> Vec<(f64, f64, f64)> { + use vectorcraft_tools::Overlay; + use vectorcraft_tools::draw2::FEEDBACK; + let v = ViewInfo::default(); + s.select_tool("selection", v).unwrap(); + set_pref(s, "realTimeDrawing", json!(false)); + s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Drag, x + 30.0, y + 40.0), v).unwrap(); + let mut boxes: Vec<_> = s + .overlays(v) + .into_iter() + .filter_map(|o| match o { + Overlay::Path { path, color, .. } if color == FEEDBACK => Some(kurbo::Shape::bounding_box(&path)), + _ => None, + }) + .map(|b| ((b.x0 * 1e3).round() / 1e3, (b.y0 * 1e3).round() / 1e3, (b.width() * 1e3).round() / 1e3)) + .collect(); + boxes.sort_by(|p, q| p.0.total_cmp(&q.0)); + boxes +} + +/// The number of subpaths in each path of the Real-time Drawing off feedback the session shows now. +fn feedback_subpaths(s: &mut Session) -> Vec { + use vectorcraft_tools::Overlay; + use vectorcraft_tools::draw2::FEEDBACK; + s.overlays(ViewInfo::default()) + .into_iter() + .filter_map(|o| match o { + Overlay::Path { path, color, .. } if color == FEEDBACK => { + Some(path.elements().iter().filter(|el| matches!(el, kurbo::PathEl::MoveTo(_))).count()) + } + _ => None, + }) + .collect() +} + +/// With Real-time Drawing off, a dragged group draws the outline of each member, nested groups' +/// members included, at the dragged position instead of one union of them all. +#[test] +fn real_time_drawing_off_outlines_a_dragged_group_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let rect = |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let (a, b, c) = (rect(&mut s, 100.0), rect(&mut s, 150.0), rect(&mut s, 200.0)); + s.execute("select.set", &json!({"ids": [b, c]})).unwrap(); + let inner = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, inner]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 100.0), (180.0, 140.0, 100.0), (230.0, 140.0, 100.0)], "one moved outline per member"); +} + +/// With Real-time Drawing off, a hidden member of a dragged group draws no outline. +#[test] +fn real_time_drawing_off_skips_a_hidden_member_of_a_dragged_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let rect = |s: &mut Session, x: f64| s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let (a, b, c) = (rect(&mut s, 100.0), rect(&mut s, 150.0), rect(&mut s, 200.0)); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + s.execute("object.hide", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 100.0), (230.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged blend draws the outline of each of its objects and steps. +#[test] +fn real_time_drawing_off_outlines_a_dragged_blend_step_by_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.blend.make", &json!({"steps": 3})).unwrap(); + let lefts: Vec = dragged_feedback_boxes(&mut s, 120.0, 120.0).iter().map(|b| b.0).collect(); + assert_eq!(lefts, [130.0, 180.0, 230.0, 280.0, 330.0], "one moved outline per blended object and step"); +} + +/// With Real-time Drawing off, a dragged compound shape draws the outline of the area it makes. +#[test] +fn real_time_drawing_off_outlines_a_dragged_compound_shape_by_its_area() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let shape = s.execute("object.compoundShape.make", &json!({"mode": "intersect"})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [shape]})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 170.0, 120.0), [(180.0, 140.0, 50.0)]); +} + +/// With Real-time Drawing off, a dragged group with a Pathfinder effect draws the outline of the area +/// the effect makes. +#[test] +fn real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "pathfinder.intersect"})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 170.0, 120.0), [(180.0, 140.0, 50.0)]); +} + +/// With Real-time Drawing off, a dragged group with a Drop Shadow draws the outline of each member +/// instead of one union of them. +#[test] +fn real_time_drawing_off_outlines_a_dragged_group_with_an_effect_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0), (180.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged clip group draws only its clipping path, as its selection +/// highlight does. +#[test] +fn real_time_drawing_off_outlines_a_dragged_clip_group_by_its_clipping_path() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let art = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 200, "height": 100})).unwrap()["id"].clone(); + let clip = s.execute("shape.rectangle", &json!({"x": 120, "y": 110, "width": 50, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [art, clip]})).unwrap(); + s.execute("object.clippingMask.make", &json!({})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 140.0, 130.0), [(150.0, 150.0, 50.0)]); +} + +/// With Real-time Drawing off, a dragged envelope draws the outline of each object it evaluates to: each +/// rectangle's fill and its outlined stroke. +#[test] +fn real_time_drawing_off_outlines_a_dragged_envelope_object_by_object() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.envelope.makeWithMesh", &json!({"rows": 1, "cols": 1})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(129.5, 139.5, 101.0), (130.0, 140.0, 100.0), (179.5, 139.5, 101.0), (180.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged Shaper group draws the outline of its result and not of the +/// source art it keeps. +#[test] +fn real_time_drawing_off_skips_the_source_art_of_a_dragged_shaper_group() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + let points = [[125.0, 120.0], [175.0, 135.0], [130.0, 150.0], [225.0, 165.0], [175.0, 180.0], [225.0, 185.0]]; + let shaper = s.execute("shaper.scribble", &json!({"points": points})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [shaper]})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 150.0), (130.0, 140.0, 150.0)], "the merged result's fill and stroke"); +} + +/// With Real-time Drawing off, a dragged symbol instance draws the bounds its selection highlight +/// shows, alone and as a member of a group. +#[test] +fn real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0)]); + s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 160.0), ViewInfo::default()).unwrap(); + s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 50, "height": 50})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 150.0, 160.0), [(160.0, 180.0, 100.0), (430.0, 140.0, 50.0)]); +} + +/// With Real-time Drawing off, a handle resize of a group draws each member's outline at its new size. +#[test] +fn real_time_drawing_off_outlines_a_resized_group_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + // The Right handle, dragged 30 pt out: the group goes from 300 pt wide to 330. + assert_eq!(dragged_feedback_boxes(&mut s, 400.0, 125.0), [(100.0, 100.0, 110.0), (320.0, 100.0, 110.0)]); +} + +/// With Real-time Drawing off, each copy of a gradient mesh in a dragged repeat draws its mesh lines +/// once, without the pieces the mesh is drawn with. +#[test] +fn real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("object.repeat.grid", &json!({"rows": 1, "cols": 2})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes.len(), 2, "one outline per copy: {boxes:?}"); + assert_eq!(boxes[0], (130.0, 140.0, 100.0), "the first copy's mesh lines, moved"); + let subpaths = feedback_subpaths(&mut s); + assert!(subpaths.len() == 2 && subpaths.iter().all(|&n| n > 1), "each copy's mesh lines, not one boundary: {subpaths:?}"); +} + +/// With Real-time Drawing off, a dragged gradient mesh with a Drop Shadow draws its mesh lines as one +/// outline, without the pieces the mesh is drawn with. +#[test] +fn real_time_drawing_off_outlines_a_dragged_mesh_with_an_effect_by_its_lines() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0)]); + let subpaths = feedback_subpaths(&mut s); + assert!(matches!(subpaths.as_slice(), [n] if *n > 1), "the mesh's lines, not one boundary: {subpaths:?}"); +} + +/// With Real-time Drawing off, a dragged group with a Vector Halftone keeps its own fill, which is what +/// the effect samples, so an unpainted member still gives an outline. +#[test] +fn real_time_drawing_off_outlines_a_dragged_halftone_group_with_its_own_fill() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"ids": [a], "none": true})).unwrap(); + s.execute("paint.setStroke", &json!({"ids": [a], "none": true})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].clone(); + s.execute("paint.setFill", &json!({"color": "#000000", "ids": []})).unwrap(); + s.execute("select.set", &json!({"ids": [group]})).unwrap(); + s.execute("appearance.addFill", &json!({})).unwrap(); + let fill = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group.as_u64().unwrap())).unwrap().appearance.fill_paint().label(); + assert_eq!(fill, "#000000", "the group's own fill"); + s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "item": null})).unwrap(); + // The Right handle: the unpainted member gives nothing to press on. + assert!(!dragged_feedback_boxes(&mut s, 200.0, 150.0).is_empty(), "the halftone's dots are outlined"); +} + +/// With Real-time Drawing off, a dragged object with Crop Marks draws the object's outline without its +/// marks. +#[test] +fn real_time_drawing_off_outlines_a_dragged_object_without_its_crop_marks() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("effect.apply", &json!({"effect": "cropMarks", "item": null})).unwrap(); + assert_eq!(dragged_feedback_boxes(&mut s, 120.0, 120.0), [(130.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, the objects a dragged repeat evaluates to count toward the limit with +/// the points of their outlines: 18,000 rectangles have 90,000 points, and with the objects they come +/// to 108,000. +#[test] +fn real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("object.repeat.grid", &json!({"rows": 120, "cols": 150})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 105.0, 105.0); + assert_eq!(boxes.len(), 1, "one bounds rectangle: {} boxes", boxes.len()); +} + +/// With Real-time Drawing off, a dragged intertwine draws the outline of each member, without the +/// area one of them is arranged over the other in. +#[test] +fn real_time_drawing_off_outlines_a_dragged_intertwine_member_by_member() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let a = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 50})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.intertwine.make", &json!({})).unwrap(); + s.execute("object.intertwine.arrange", &json!({"at": [175, 125], "order": "back"})).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 120.0, 120.0); + assert_eq!(boxes, [(130.0, 140.0, 100.0), (180.0, 140.0, 100.0)]); +} + +/// With Real-time Drawing off, a dragged group whose members have more than 100,000 points between +/// them shows its moved bounds, as a single path of that size does. +#[test] +fn real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 600, "height": 600})).unwrap(); + let path = |s: &mut Session, y: f64| { + let anchors: Vec = + (0..60_000).map(|i| json!({"x": 100.0 + f64::from(i) * 0.003, "y": if i % 2 == 0 { y } else { y + 50.0 }})).collect(); + s.execute("path.create", &json!({"anchors": anchors, "closed": false})).unwrap()["id"].clone() + }; + let (a, b) = (path(&mut s, 100.0), path(&mut s, 200.0)); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + s.execute("object.group", &json!({})).unwrap(); + let at_rest = s.doc().unwrap().doc.bounds_of(&s.doc().unwrap().selection.objects, true).unwrap(); + let boxes = dragged_feedback_boxes(&mut s, 200.0, 100.0); + let [(x, y, width)] = boxes.as_slice() else { panic!("one bounds rectangle, got {boxes:?}") }; + assert!((x - at_rest.x0 - 30.0).abs() < 1e-2 && (y - at_rest.y0 - 40.0).abs() < 1e-2 && (width - at_rest.width()).abs() < 1e-2, "{boxes:?}"); +} + /// A deferred commit that fails its preview cancels the interaction instead of leaving it open. #[test] fn deferred_commit_preview_failure_cancels_interaction() { diff --git a/crates/engine/src/tests/scripts.rs b/crates/engine/src/tests/scripts.rs index 5f620b251..8d2add2ea 100644 --- a/crates/engine/src/tests/scripts.rs +++ b/crates/engine/src/tests/scripts.rs @@ -140,7 +140,7 @@ fn pipes_and_folders_are_not_read() { let direct = s.execute("script.check", &json!({ "path": pipe })).map_err(|e| e.to_string()); let _ = tx.send((included.map_err(|e| e.to_string()), direct)); }); - let (included, direct) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("the checks return"); + let (included, direct) = rx.recv_timeout(std::time::Duration::from_secs(300)).expect("the checks return"); let statuses: Vec = included.unwrap().as_array().unwrap().iter().map(|i| i["status"].as_str().unwrap().to_string()).collect(); assert_eq!(statuses, ["missing", "missing"]); assert!(direct.is_err_and(|e| e.contains("not a file"))); diff --git a/crates/engine/src/tests/swatchcmds.rs b/crates/engine/src/tests/swatchcmds.rs index b0d0edf86..2a74e194d 100644 --- a/crates/engine/src/tests/swatchcmds.rs +++ b/crates/engine/src/tests/swatchcmds.rs @@ -3,7 +3,7 @@ //! document's colour mode. use serde_json::{Value, json}; -use vectorcraft_color::Paint; +use vectorcraft_color::{Color, Paint}; use crate::*; @@ -125,6 +125,275 @@ fn add_used_colors_skips_colours_that_have_swatches() { assert!(doc(&s).swatch("R=171 G=205 B=239").unwrap().global); } +/// Give `id` the fill `p` exactly as written (a command would round or convert some colors). +fn set_fill(s: &mut Session, id: NodeId, p: Paint) { + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.set_fill(p); +} + +/// Rectangles filled with repeated colors, near-equal colors that get the same name, a color and +/// its twin with a -0.0 component, a color whose name a swatch already has, and colors two +/// swatches already have (one of them through -0.0). Returns the rectangles in paint order. +fn used_colors_doc(s: &mut Session) -> Vec { + run(s, "swatch.new", json!({"name": "Gray K=33", "color": "#00ff00"})); + run(s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"})); + let red = doc(s).swatch("Red").unwrap().paint.color().unwrap(); + let colors = [ + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6001), + Color::rgb(0.0, 0.4, 0.6), + Color::rgb(-0.0, 0.4, 0.6), + Color::gray(0.33), + Color::gray(0.3301), + red, + Color::rgb(-0.0, 128.0 / 255.0, 1.0), + Color::rgb(0.2, 0.4, 0.6001), + ]; + colors + .into_iter() + .map(|c| { + let id = rect_filled(s, json!({"color": "#000000"})); + set_fill(s, id, Paint::solid(c)); + id + }) + .collect() +} + +const USED_NAMES: [&str; 5] = ["R=51 G=102 B=153", "R=51 G=102 B=153 2", "R=0 G=102 B=153", "Gray K=33 2", "Gray K=33 3"]; + +#[test] +fn add_used_colors_adds_each_new_color_once_with_free_names() { + let mut s = session(); + used_colors_doc(&mut s); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.addUsedColors", json!({})); + assert_eq!(r, json!({"added": USED_NAMES, "linked": 0})); + assert_eq!(names(&s, None)[names(&s, None).len() - 5..], USED_NAMES); + let color = |n: &str| doc(&s).swatch(n).unwrap().paint.color().unwrap(); + assert_eq!(color("R=51 G=102 B=153 2"), Color::rgb(0.2, 0.4, 0.6001)); + assert_eq!(color("Gray K=33 3"), Color::gray(0.3301)); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + +#[test] +fn add_used_colors_global_links_every_paint_of_a_new_color() { + let mut s = session(); + let ids = used_colors_doc(&mut s); + let r = run(&mut s, "swatch.addUsedColors", json!({"global": true})); + assert_eq!(r, json!({"added": USED_NAMES, "linked": 8})); + let link = |s: &Session, id: NodeId| match fill(s, id) { + Paint::Solid { swatch, .. } => swatch, + p => panic!("{p:?}"), + }; + // The index in USED_NAMES of the swatch each rectangle links to. + let want = [Some(0), Some(0), Some(1), Some(2), Some(2), Some(3), Some(4), None, None, Some(1)]; + let got: Vec> = ids.iter().map(|id| link(&s, *id)).collect(); + assert_eq!(got, want.map(|i: Option| i.map(|i| USED_NAMES[i].to_string()))); + assert!(USED_NAMES.iter().all(|n| doc(&s).swatch(n).unwrap().global)); +} + +/// A paint linked to Registration keeps its link when a new global swatch has the same color. +#[test] +fn add_used_colors_global_leaves_registration_links_alone() { + let mut s = session(); + let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + let registration = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, registration, reg_paint.clone()); + let plain = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, plain, Paint::solid(Color::cmyk(1.0, 1.0, 1.0, 1.0))); + let r = run(&mut s, "swatch.addUsedColors", json!({"global": true})); + assert_eq!(r["linked"], json!(1), "{r}"); + let name = r["added"][0].as_str().unwrap().to_string(); + assert_eq!(fill(&s, registration), reg_paint); + assert_eq!(fill(&s, plain), Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(name), tint: 1.0 }); +} + +#[test] +fn unused_matches_links_tints_colors_gradients_and_patterns_exactly() { + let mut s = session(); + run(&mut s, "swatch.edit", json!({"name": "Teal", "global": true})); + run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 40})); + run(&mut s, "swatch.new", json!({"swatch": "Teal", "tint": 70})); + run(&mut s, "swatch.new", json!({"name": "Zeroed", "color": "#0080ff"})); + let teal = doc(&s).swatch("Teal").unwrap().paint.color().unwrap(); + let tinted = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, tinted, Paint::Solid { color: teal.tinted(0.4), swatch: Some("Teal".into()), tint: 0.4 }); + let zero = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, zero, Paint::solid(Color::rgb(-0.0, 128.0 / 255.0, 1.0))); + // Sunset's gradient without a link to it, beside gradients with Sunset's stop colors and a + // different midpoint or opacity; Ocean's with one stop moved, and Ocean's with another kind, + // method or dither. + let gradient = |s: &Session, n: &str| match &doc(s).swatch(n).unwrap().paint { + Paint::Gradient(g) => g.gradient.clone(), + p => panic!("{p:?}"), + }; + let sunset = gradient(&s, "Sunset"); + let (mut midpoint, mut opacity) = (sunset.clone(), sunset.clone()); + midpoint.stops[0].midpoint = 0.3; + opacity.stops[1].opacity = 0.5; + let ocean = gradient(&s, "Ocean"); + let (mut moved, mut radial, mut perceptual, mut dither) = (ocean.clone(), ocean.clone(), ocean.clone(), ocean); + moved.stops[0].offset += 0.25; + radial.kind = vectorcraft_color::GradientKind::Radial; + perceptual.method = vectorcraft_color::GradientMethod::Perceptual; + dither.dither = true; + for g in [midpoint, sunset.clone(), opacity, sunset, moved, radial, perceptual, dither] { + let id = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, id, Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(g)))); + } + let r = rect_filled(&mut s, json!({"color": "#ff0000"})); + run(&mut s, "select.set", json!({"ids": [r.0]})); + run(&mut s, "object.pattern.make", json!({"name": "Dots", "width": 20, "height": 20})); + run(&mut s, "object.pattern.done", json!({})); + rect_filled(&mut s, json!({"swatch": "Dots"})); + let names = run(&mut s, "swatch.unused", json!({}))["names"].clone(); + let names: Vec<&str> = names.as_array().unwrap().iter().filter_map(Value::as_str).collect(); + for used in ["Teal", "Teal 40%", "Zeroed", "Sunset", "Dots"] { + assert!(!names.contains(&used), "{used} is used: {names:?}"); + } + for unused in ["Teal 70%", "Ocean", "Orange", "Red"] { + assert!(names.contains(&unused), "{unused} is unused: {names:?}"); + } +} + +#[test] +fn new_swatches_from_colors_are_numbered_after_names_already_taken() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"})); + run(&mut s, "swatch.newGroup", json!({"name": "Mine"})); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component. + let colors = json!([ + "#ff0000", + "#ff0000", + "#ff0000", + "#00ff00", + "#00ff00", + [0.2, 0.4, 0.6], + [0.2, 0.4, 0.6001], + {"l": 50, "a": 0.0, "b": 0}, + {"l": 50, "a": -0.0, "b": 0} + ]); + let r = run(&mut s, "swatch.new", json!({ "colors": colors })); + let want = [ + "R=255 G=0 B=0", + "R=255 G=0 B=0 3", + "R=255 G=0 B=0 4", + "R=0 G=255 B=0", + "R=0 G=255 B=0 2", + "R=51 G=102 B=153", + "R=51 G=102 B=153 2", + "L=50 a=0 b=0", + "L=50 a=0 b=0 2", + ]; + assert_eq!(r, json!({"name": want[0], "names": want})); + assert_eq!(names(&s, None)[names(&s, None).len() - want.len()..], want); + assert!(matches!(doc(&s).swatch("L=50 a=0 b=0 2").unwrap().paint.color(), Some(Color::Lab { a, .. }) if a.is_sign_negative())); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); + let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000", "#ff0000"]})); + assert_eq!(r["names"], json!(["R=255 G=0 B=0", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4"])); + let r = run(&mut s, "swatch.new", json!({"colors": ["#ff0000", "#ff0000"], "group": "Mine"})); + assert_eq!(r["names"], json!(["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"])); + assert_eq!(names(&s, Some("Mine")), ["R=255 G=0 B=0 5", "R=255 G=0 B=0 6"]); +} + +#[test] +fn new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=255 G=0 B=0 2", "color": "#0000ff"})); + let r = run( + &mut s, + "swatch.newGroup", + json!({"name": "R=255 G=0 B=0", "swatches": ["R=255 G=0 B=0 2"], "colors": ["#ff0000", "#ff0000", "#00ff00", "#ff0000"]}), + ); + let want = ["R=255 G=0 B=0 2", "R=255 G=0 B=0 3", "R=255 G=0 B=0 4", "R=0 G=255 B=0", "R=255 G=0 B=0 5"]; + assert_eq!(r, json!({"name": "R=255 G=0 B=0", "swatches": want, "linked": 0})); +} + +/// New Color Group from artwork lists each color once in paint order: a repeat and a twin with a +/// -0.0 component are the same color, near-equal colors with the same name are numbered after the +/// names already taken, and a paint linked to Registration brings no color. One undo step. +#[test] +fn new_color_group_from_artwork_lists_each_color_once_with_free_names() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "R=0 G=102 B=153", "color": "#00ff00"})); + let colors = [ + Color::rgb(0.2, 0.4, 0.6), + Color::rgb(0.0, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6001), + Color::rgb(-0.0, 0.4, 0.6), + Color::rgb(0.2, 0.4, 0.6), + Color::lab(50.0, 0.0, 0.0), + Color::lab(50.0, -0.0, 0.0), + Color::lab(50.0, 0.0001, 0.0), + ]; + let mut ids: Vec = vec![]; + for c in colors { + let id = rect_filled(&mut s, json!({"color": "#000000"})); + set_fill(&mut s, id, Paint::solid(c)); + ids.push(id.0); + } + let registration = rect_filled(&mut s, json!({"color": "#000000"})); + let reg_paint = Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(vectorcraft_color::swatch::REGISTRATION.into()), tint: 1.0 }; + set_fill(&mut s, registration, reg_paint); + ids.push(registration.0); + run(&mut s, "select.set", json!({ "ids": ids })); + let before = doc(&s).clone(); + let undo = undo_len(&s); + let r = run(&mut s, "swatch.newGroup", json!({"name": "R=51 G=102 B=153 2", "fromArtwork": true})); + let want = ["R=51 G=102 B=153", "R=0 G=102 B=153 2", "R=51 G=102 B=153 3", "L=50 a=0 b=0", "L=50 a=0 b=0 2"]; + assert_eq!(r, json!({"name": "R=51 G=102 B=153 2", "swatches": want, "linked": 8})); + assert_eq!(names(&s, Some("R=51 G=102 B=153 2")), want); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + +#[test] +fn edit_color_group_numbers_extra_colors_after_the_names_it_keeps() { + let mut s = session(); + run(&mut s, "swatch.newGroup", json!({"name": "Mine", "colors": ["#ff0000", "#00ff00", "#0000ff"]})); + let before = doc(&s).clone(); + let undo = undo_len(&s); + // Near-equal colors with the same name, and a Lab color and its twin with a -0.0 component. + let colors = json!([ + "#0000ff", + "#ff0000", + "#ff0000", + "#ff0000", + "#ff0000", + "#00ff00", + [0.2, 0.4, 0.6], + [0.2, 0.4, 0.6001], + {"l": 50, "a": 0.0, "b": 0}, + {"l": 50, "a": -0.0, "b": 0} + ]); + let r = run(&mut s, "swatch.editGroup", json!({"group": "Mine", "colors": colors, "rename": "R=255 G=0 B=0 2"})); + let want = [ + "R=255 G=0 B=0", + "R=0 G=255 B=0", + "R=0 G=0 B=255", + "R=255 G=0 B=0 3", + "R=255 G=0 B=0 4", + "R=0 G=255 B=0 2", + "R=51 G=102 B=153", + "R=51 G=102 B=153 2", + "L=50 a=0 b=0", + "L=50 a=0 b=0 2", + ]; + assert_eq!(r, json!({"name": "R=255 G=0 B=0 2", "swatches": want, "relinked": 0})); + assert_eq!(names(&s, Some("R=255 G=0 B=0 2")), want); + assert_eq!(undo_len(&s), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(*doc(&s), before); +} + #[test] fn unused_leaves_out_linked_colours_gradients_and_patterns_in_use() { let mut s = session(); @@ -234,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() { // A swatch made from a hex colour is CMYK as well; the document round-trips natively. let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string(); assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0)))); - assert_eq!(name, "C=62 M=0 Y=100 K=0"); + assert_eq!(name, "C=61 M=0 Y=100 K=0"); let json = serde_json::to_string(doc(&s)).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), *doc(&s)); } diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 2d85d620f..1871722c3 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -339,6 +339,7 @@ impl Session { transform_tools_guides: self.prefs.transform_tools_guides, spacing_guides: self.prefs.spacing_guides, distance_guides: self.prefs.distance_guides, + snap_to_last_location: self.prefs.snap_to_last_location, snapping_tolerance: self.prefs.snapping_tolerance, construction_angles: if self.prefs.construction_guides { vectorcraft_tools::guides::construction_angles(&self.prefs.construction_angles) @@ -1041,23 +1042,13 @@ impl Session { /// `crates/ui-egui/src/canvas/outline.rs`). Past it the selection's bounds are drawn instead. const FEEDBACK_MAX_POINTS: usize = 100_000; -/// The outline of every selected object, or the selection's bounds when the outlines have more than -/// [`FEEDBACK_MAX_POINTS`] points. +/// The outlines of every selected object, a group's members each with their own, or the selection's +/// bounds when the outlines' points and the objects evaluated to make them come to more than +/// [`FEEDBACK_MAX_POINTS`]. fn outlines_for_selection(doc: &vectorcraft_doc::Document, selection: &Selection) -> Vec { - let mut left = FEEDBACK_MAX_POINTS; - let mut outlines = vec![]; - for &id in &selection.objects { - let Some((path, _)) = doc.node(id).and_then(|n| vectorcraft_render::drawn_outline(doc, n)) else { continue }; - let points = path.elements().iter().filter(|el| !matches!(el, kurbo::PathEl::ClosePath)).count(); - let Some(l) = left.checked_sub(points) else { - return doc - .bounds_of(&selection.objects, true) - .map(|b| Overlay::Path { path: vectorcraft_geom::shapes::rectangle(b).to_bezpath(), color: FEEDBACK, width: 1.0, dashed: false }) - .into_iter() - .collect(); - }; - left = l; - outlines.push(Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: false }); - } - outlines + let nodes = selection.objects.iter().filter_map(|&id| doc.node(id)); + let outlines = vectorcraft_render::drawn_outlines(doc, nodes, FEEDBACK_MAX_POINTS).unwrap_or_else(|| { + doc.bounds_of(&selection.objects, true).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()).into_iter().collect() + }); + outlines.into_iter().map(|path| Overlay::Path { path, color: FEEDBACK, width: 1.0, dashed: false }).collect() } diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 42970aa16..5f16715da 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -243,6 +243,10 @@ fn structured_junk() { ("addon.select.byLinkStatus", json!({"status": "embedded", "ids": {"a": 1}, "add": [1], "zoom": "yes"})), ("addon.select.byLinkStatus", json!({"status": ["modified", "embedded"], "ids": [], "add": true, "zoom": true})), ("addon.select.byLinkStatus", json!({"status": [[["linked"]]], "ids": null})), + ("links.relink", json!({"scope": "each", "paths": [{"ids": [2, u64::MAX, -1, "x"], "path": ""}, null, 7, {"path": [1]}]})), + ("links.relink", json!({"scope": ["all"], "path": "/no/such/file.png", "ids": {"a": 1}})), + ("links.relink", json!({"scope": "missingInFolder", "path": "", "folder": [], "paths": "x"})), + ("links.relink", json!({"scope": "all", "path": "/no/such/file.png", "ids": [2, u64::MAX, -1, 1.5]})), ("object.transform", json!({"matrix": [0, 0, 0, 0, 0, 0]})), ("object.transform", json!({"matrix": [1e308, 1e308, 1e308, 1e308, 1e308, 1e308]})), ("object.transform", json!({"matrix": [1, 2, 3]})), @@ -911,7 +915,15 @@ fn every_command_undoes_exactly() { let mut s = Fixture::Multi.session(); let before = doc_json(&s.doc().unwrap().doc); let depth = s.doc().unwrap().history.undo.len(); + let source = s.active_index().unwrap(); let Ok(Ok(_)) = catch_quiet(|| s.execute(c.id, &json!({}))) else { continue }; + // A command that makes a new document and makes it active leaves the source document as it is. + if s.active_index() != Some(source) { + if s.documents().get(source).map(|d| doc_json(&d.doc)).as_ref() != Some(&before) { + failures.push(format!("{}: changed the source document while making another active", c.id)); + } + continue; + } if s.doc().unwrap().history.undo.len() > depth { let _ = no_panic(s.execute("edit.undo", &json!({}))); let after = doc_json(&s.doc().unwrap().doc); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 6c0f3167c..73c497032 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -1148,6 +1148,42 @@ fn styles(what: &str, data: &str) -> Result<(), TestCaseError> { }) } +/// A document with type and a character and a paragraph style, saved as pretty native text: what +/// Load All Styles reads. +fn saved_text_styles() -> String { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Styled"})).unwrap(); + s.execute("charStyle.new", &json!({"name": "Lead", "attrs": {"size": 30, "tracking": 50, "underline": true, "leading": 36}})).unwrap(); + s.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center", "space_after": 6, "hyphenate": true}})).unwrap(); + let options = vectorcraft_format::SaveOptions { version: 2, pretty: true, ..vectorcraft_format::SaveOptions::default() }; + let text = String::from_utf8(vectorcraft_format::save_with(&s.doc().unwrap().doc, &options).unwrap()).unwrap(); + // Undamaged, it loads both styles. + let r = rich_session().execute("textStyle.loadAll", &json!({"data": text, "name": "fuzz.vectorcraft"})).unwrap(); + assert_eq!((&r["charStyles"]["added"], &r["paraStyles"]["added"]), (&json!(["Lead"]), &json!(["Centered"]))); + text + }) + .clone() +} + +/// Native file text `data`'s styles loaded into the rich document by Load All Styles, and each +/// style it added applied to all of its type. +fn text_styles(what: &str, data: &str) -> Result<(), TestCaseError> { + survive(what, || { + let mut s = rich_session(); + let r = no_panic(s.execute("textStyle.loadAll", &json!({"data": data, "name": "fuzz.vectorcraft"}))).ok()?; + let _ = no_panic(s.execute("select.all", &json!({}))); + for (list, kind) in [("charStyles", "charStyle"), ("paraStyles", "paraStyle")] { + for name in r[list]["added"].as_array().into_iter().flatten() { + let _ = no_panic(s.execute(&format!("{kind}.apply"), &json!({"name": name}))); + } + } + Some((*s.doc().ok()?.doc).clone()) + }) +} + /// Libraries panel library file `data` imported, then each kind's first item used on the rich /// document, moved into its first group and next to its kind's last item, a colour and the /// selection added into that group next to the first ones, the group moved and deleted and the @@ -1312,6 +1348,11 @@ proptest! { styles("mutated style library", &mutate_text(&text, cut, &edits))?; } + #[test] + fn mutated_text_style_files_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) { + text_styles("mutated text styles", &mutate_text(&saved_text_styles(), cut, &edits))?; + } + #[test] fn mutated_flattener_presets_never_panic(cut in 0usize..5_000, edits in prop::collection::vec(arb_edit(), 0..10)) { let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]})); @@ -3288,6 +3329,40 @@ proptest! { survive("hostile .ai envelope", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose warped type is damaged or hostile: any warp style and settings, on the object or + /// on a fill, a look that reaches far away or holds type of its own, the text cut short. + #[test] + fn ai_warped_type_never_panics( + style in prop::sample::select(vec![0i64, 1, 8, 15, 16, -3, i64::MAX]), + bend in prop::sample::select(vec!["0.5", "0", "1", "-1", "2", "1e308", "-1e308", "nan"]), + distort in prop::sample::select(vec!["0", "0.4", "-1", "1e308", "nan"]), + reach in prop::sample::select(vec!["90", "16000", "1e7", "1e308", "-1e308", "nan"]), + on_fill in any::(), + look_type in any::(), + objects in 1usize..4, + cut in 0usize..3_000, + edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', ' ', '\n', '(', ')', '/', ':', ';', ',', '[', ']', '%', '_', 'X', 'u', 'U'])), 0..8), + ) { + let basic = |name: &str, body: &str| format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n"); + let chain = |kids: &[String]| format!("/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 1 1 0 1 0 0 Xa\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"); + let warp = basic("Adobe Deform", &format!("{style} /Int (DeformStyle) ,\n{bend} /Real (DeformValue) ,\n{distort} /Real (DeformHoriz) ,\n{distort} /Real (DeformVert) ,\n0 /Bool (Rotate) ,\n")); + let root = if on_fill { + let stack = format!("/CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n{}{};\n /Part ,\n", chain(&[warp, fill]), basic("Conduit Filter", "")); + chain(&[stack]) + } else { + chain(&[fill, warp]) + }; + let styles = format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Warped) /Name ,\n/ActiveStyle :\n{root}%AI9_EndArtStyles\n"); + let slot = if look_type { "/AI11Text :\n1 /FreeUndo ,\n0 /FrameIndex ,\n0 /StoryIndex ,\n;\n" } else { "" }; + let object = format!( + "u\n{slot}0 1 1 0 1 0 0 Xa\n10 10 m\n{reach} 10 L\n{reach} {reach} L\n10 90 L\nf\nU\n9 () XW\n%_/AI11Text :\n%_0 /FreeUndo ,\n%_0 /FrameIndex ,\n%_0 /StoryIndex ,\n%_;\n1 (Warped) XW\n" + ); + let data = mutate_text(&text_editing_of(&vertical_document_text(), &(styles + &object.repeat(objects))), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("hostile .ai warped type", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose editing data is damaged or hostile. #[test] fn ai_editing_data_never_panics(cut in 0usize..3_000, edits in prop::collection::vec((0usize..3_000, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', '[', ']', '%', 'q', 'Q', 'W', 'u', 'U', 'L', 'l', 'c', 'C', 'v', 'V', 'y', 'Y', 'k', 'x'])), 0..12), damage in prop::collection::vec((0usize..4_000, any::()), 0..4)) { @@ -3390,6 +3465,26 @@ proptest! { survive("hostile .ai line widths", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; } + /// A `.ai` whose legacy type is set in a CJK composite font (a predefined CMap the reader + /// carries, one it reads through another, one it decodes byte by byte, a Unicode CMap, or a + /// name that only looks like one), with any bytes in its strings. + #[test] + fn ai_legacy_type_in_cjk_fonts_never_panics( + cmap in prop::sample::select(vec![ + "83pv-RKSJ-H", "90ms-RKSJ-V", "90pv-RKSJ-V", "H", "V", "EUC-H", "GBK2K-H", "CNS-EUC-V", "KSCpc-EUC-H", "KSCms-UHC-HW-V", + "78-RKSJ-H", "GB-H", "KSC-V", "HKdlb-B5-H", "GBT-H", "UniJIS-UCS2-H", "UniGB-UTF8-V", "UniKS-UTF32-H", "UniAKR-UTF16-H", + "Identity-H", "UniFoo-UTF8-H", "-H", "RKSJ", + ]), + strings in prop::collection::vec(prop::collection::vec(any::(), 0..24), 1..4), + ) { + use vectorcraft_testkit::ai; + let tx: String = strings.iter().map(|s| format!("({}) Tx\n", s.iter().map(|b| format!("\\{b:03o}")).collect::())).collect(); + let art = format!("0 To\n1 0 0 1 10 50 0 Tp\nTP\n0 Tr\n/_Test-W3-{cmap} 9 Tf\n{tx}(\\r) TX\nTO\n"); + let data = ai::editing_data(100.0, 100.0, &ai::layer("L", &art)); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&data)].concat(); + survive("legacy type in CJK fonts", || Some(vectorcraft_eps::layered_ai(&private, Document::new(100.0, 100.0), vec![], false, false, 1.0).0))?; + } + /// A `.ai` whose pattern definition (a marked tile box, a gradient placed by `Bg`'s matrix) and /// pattern fill are damaged or hostile. #[test] @@ -3704,8 +3799,9 @@ proptest! { } /// Brush objects of every kind with any parameters, the brush manager's order with any names, - /// and brush art sections nested or cut short: read with the layers, or as a library without - /// them, the brushes kept within their ranges. + /// and brush art sections nested or cut short, their art alone or in a group after a frame of + /// any corners, painted or not, open or closed, the group ended or not: read with the layers, + /// or as a library without them, the brushes kept within their ranges. #[test] fn ai_brushes_never_panic( kind in 0usize..7, @@ -3714,6 +3810,7 @@ proptest! { nest in 0usize..6, cut in any::(), layers in any::(), + frame in prop::option::of((prop::collection::vec(prop_oneof![arb_num(), Just("0".to_string()), Just("4".to_string())], 4), prop::sample::select(vec!["n", "N", "f", "S"]), any::())), ) { use vectorcraft_testkit::ai; let kinds = ["Adobe Calligraphic Brush Tool", "Adobe Scatter Brush Tool", "Adobe ArtOnPath Brush Tool", "Adobe PatternOnPath Brush Tool", "Adobe dBrush Brush Tool", "Adobe Brush Manager Order", "Other"]; @@ -3727,7 +3824,14 @@ proptest! { for _ in 0..nest { art.push_str("%AI8_BeginBrushPattern\n(A)\n"); } - art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n"); + match &frame { + Some((c, op, ended)) => { + let (x0, y0, x1, y1) = (&c[0], &c[1], &c[2], &c[3]); + let end = if *ended { "U\n" } else { "" }; + art.push_str(&format!("u\n{x0} {y0} m\n{x1} {y0} L\n{x1} {y1} L\n{x0} {y1} L\n{x0} {y0} L\n{op}\n0 g\n0 0 m\n4 0 L\n4 4 L\nf\n{end}")); + } + None => art.push_str("0 g\n0 0 m\n4 0 L\n4 4 L\nf\n"), + } for _ in 0..nest.saturating_sub(usize::from(cut)) { art.push_str("%AI8_EndBrushPattern\n"); } @@ -3750,6 +3854,91 @@ proptest! { survive("mutated .ai symbols", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; survive("mutated .ai symbols, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; } + + /// A `.ai` whose art styles, as the writer writes them, are damaged: the styles, the style + /// list, a styled object's drawn look, the object on `%_` lines and its `1 (style) XW`, cut + /// short or edited. + #[test] + fn ai_written_styles_never_panic(cut in 0usize..STYLES_FUZZ_CHARS, edits in prop::collection::vec((0usize..STYLES_FUZZ_CHARS, prop::sample::select(vec!['0', '9', '-', '.', ' ', '\n', '(', ')', '/', ':', ';', ',', '%', '_', 'u', 'U', 'X', 'W'])), 0..12)) { + let text = mutate_text(styles_text(), cut, &edits); + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(&text)].concat(); + survive("mutated .ai art styles", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, false, 1.0).0))?; + survive("mutated .ai art styles, trusted", || Some(vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0).0))?; + } +} + +/// The range of [`ai_written_styles_never_panic`]'s cuts and edits: past the end of [`styles_text`]. +const STYLES_FUZZ_CHARS: usize = 64_000; + +/// The editing data VectorCraft writes for a document with a drop shadow on a path, a gradient fill +/// and a width profile on others, a stroked even-odd path with an opacity mask and Pathfinder › Add +/// on a group. +fn styles_text() -> &'static str { + static TEXT: std::sync::OnceLock = std::sync::OnceLock::new(); + TEXT.get_or_init(|| { + use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint}; + use vectorcraft_doc::{Appearance, AppearanceItem, Node, NodeKind, OpacityMask, StrokeLayer, WidthProfile}; + use vectorcraft_geom::{FillRule, Rect, shapes}; + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let fx = |id: &str| vectorcraft_render::effects::new_effect(id, &json!({})).unwrap(); + let mut ap = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 2.0); + ap.effects = vec![fx("stylize.dropShadow")]; + let shaded = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap.clone()); + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let ramp = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]); + let mut graded = Appearance::basic(Paint::Gradient(Box::new(GradientPaint::new(ramp))), Paint::None, 0.0); + graded.effects = vec![fx("stylize.outerGlow")]; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 50.0, 50.0, 90.0)), graded); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut tapered = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0); + tapered.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.5), (1.0, 0.0, 1.0)] }); + let profiled = Appearance { items: vec![AppearanceItem::Stroke(tapered)], effects: vec![fx("stylize.roundCorners")], contents_index: None }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 150.0, 40.0)), profiled); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let mut stroked = Appearance::basic(Paint::None, Paint::solid(Color::WHITE), 2.0); + stroked.effects = vec![fx("stylize.dropShadow")]; + let mut odd = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 90.0)), stroked); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + let mask_art = Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(100.0, 50.0, 150.0, 70.0)), + Appearance::basic(Paint::solid(Color::gray(0.0)), Paint::None, 0.0), + ); + odd.extras_mut().mask = Some(Box::new(OpacityMask::new(mask_art, false))); + doc.insert(Some(layer), usize::MAX, odd).unwrap(); + let square = |doc: &mut Document, x: f64| { + Node::path( + doc.alloc_id(), + shapes::rectangle(Rect::new(x, 50.0, x + 30.0, 80.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ) + }; + let (a, b) = (square(&mut doc, 60.0), square(&mut doc, 80.0)); + let mut merged = Node::group(doc.alloc_id(), vec![std::sync::Arc::new(a), std::sync::Arc::new(b)]); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add")], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, merged).unwrap(); + let out = vectorcraft_eps::export_ai_editing(&doc, &vectorcraft_eps::AiEditingOptions::default()).unwrap(); + String::from_utf8(out.program).unwrap() + }) +} + +/// The data [`ai_written_styles_never_panic`] damages, as it is, opens with its styles live, and the +/// property's cuts and edits reach all of it. +#[test] +fn the_written_styles_fuzz_data_opens_live() { + let text = styles_text(); + assert!(text.chars().count() < STYLES_FUZZ_CHARS, "{}", text.chars().count()); + for mark in ["%AI9_BeginArtStyles", "%AI5_BeginLayer", "\n6 () XW", "\n1 (Anon", "(BSLeftWidthsArray)", "0 /Bool (UseEvenOdd)", "Bb"] { + assert!(text.contains(mark), "{mark}"); + } + let private = [b"%AI24_ZStandard_Data".as_slice(), &zstd(text)].concat(); + let (d, warnings, layered) = vectorcraft_eps::layered_ai(&private, Document::new(200.0, 100.0), vec![], false, true, 1.0); + assert!(layered, "{warnings:?}"); + let art = d.layers[0].children().unwrap(); + assert_eq!(art.iter().map(|n| n.appearance.effects.len()).collect::>(), [1, 1, 1, 1, 1]); } /// Editing data with symbols (S2's art holds an instance of S1, F's holds non-native art), the @@ -4382,6 +4571,8 @@ fn arb_ai_token() -> impl Strategy { "1e308 Ty", "1 TY", "(x) TY", + "3 Tg", + "1e308 Tg", "1 1 1e308 -1e308 4e9 Th", "1 1 (2) 3 4 Th", "36 2 Xb", diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs new file mode 100644 index 000000000..6c84c66b3 --- /dev/null +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -0,0 +1,102 @@ +//! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`) +//! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these +//! tests run both over one set of scripts and fail on any disagreement about the lines a script has, +//! its preprocessor lines, and where a string, comment or regular expression that is never closed +//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which +//! only the checker reads). + +use vectorcraft_js::{Options, TokenKind, parse, tokenize}; +use vectorcraft_scriptcheck::{Limits, check}; + +/// The problems the checker reports from its lexer, which the parser reports as a `SyntaxError`. +const LEXICAL: [(&str, &str); 3] = [ + ("unterminatedString", "unterminated string"), + ("unterminatedComment", "unterminated comment"), + ("unterminatedRegExp", "unterminated regular expression"), +]; + +/// Scripts both lexers read without a problem, each with a `/` or a quote in a place where reading +/// it the other way would change the tokens. +const VALID: &[&str] = &[ + "var a = b / c / d;\nvar r = /ab+c/gi;\n", + "if (x) /re/.test(y);\nwhile (z) /q/g.exec(w);\n", + "function f() { return /x/; }\nvar g = function () {} / 2;\n", + "{}\n/re/.test(s);\n", + "x = {a: 1} / 2;\n", + "a = b\n/c/g\n", + "s = 'it\\'s \"q\" // not a comment';\nt = \"/* nor this */\";\n", + "s = 'line\\\ncontinued';\nu = 1;\n", + "x = a++ / 2;\ny = (b) / 2;\nz = c[0] / 2;\n", + "r = /[/]/;\nq = /\\//;\n", + "#target vectorcraft\n#include \"lib.jsxinc\"\n//@includepath \"~/x\"\nvar a = 1;\n#targetengine session\n", + "a = 1;\r\nb = 2;\r\nc = 3;", + "a = 1;\u{2028}b = 2;\u{2029}c = 3;\n", + "typeof /x/ === 'object';\nvoid /y/;\n", + "switch (x) { case /a/.test(y): break; }\n", + "x = y ? /a/ : /b/;\n", + "label: for (;;) { break label; }\n/* multi\nline */ z = 1 / 2;\n", + "var o = { re: /x/, d: 4 / 2 };\n", + "x = [/a/, 1 / 2];\n", + "if (a) {\n} else /x/.test(b);\n", + "a = 'q' + \"r\";\n// a comment with a 'quote\n", + "x = a\n++b\n", + "for (var i = 0; i < n / 2; i++) { s += /\\d/.exec(t[i]) ? 1 : 0; }\n", + "", + "\n\n", +]; + +/// Scripts with a string, comment or regular expression that is never closed: the problem the +/// checker gives and the line it starts on. +const INVALID: &[(&str, &str, usize)] = &[ + ("var a = 1;\nvar s = 'abc;\n", "unterminatedString", 2), + ("a = 1;\n/* never closed\nb = 2;\n", "unterminatedComment", 2), + ("x = 1;\ny = /abc\n", "unterminatedRegExp", 2), + ("if (x)\n /abc\n", "unterminatedRegExp", 2), + ("a = 1;\r\nb = \"x\r\n", "unterminatedString", 2), + ("q = 2 / 3;\nr = /[/]\n", "unterminatedRegExp", 2), +]; + +fn js_options() -> Options { + Options { preprocessor_lines: true, ..Options::default() } +} + +#[test] +fn both_lexers_count_the_same_lines_and_read_the_same_directives() { + for src in VALID { + let program = parse(src, &js_options()).unwrap_or_else(|e| panic!("{src:?}: {e}")); + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + assert_eq!(report.lines, program.line_count() as usize, "lines of {src:?}"); + let lexical: Vec<_> = report.problems.iter().filter(|p| LEXICAL.iter().any(|(code, _)| *code == p.code)).collect(); + assert!(lexical.is_empty(), "{src:?}: the checker reports {lexical:?}"); + let theirs: Vec<(String, usize)> = report.directives.iter().map(|d| (d.name.to_string(), d.line)).collect(); + let ours: Vec<(String, usize)> = program.directives.iter().map(|d| (d.name.to_lowercase(), d.line as usize)).collect(); + assert_eq!(ours, theirs, "directives of {src:?}"); + } +} + +#[test] +fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { + for (src, code, line) in INVALID { + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + let theirs = report.problems.iter().find(|p| LEXICAL.iter().any(|(c, _)| *c == p.code)); + let theirs = theirs.unwrap_or_else(|| panic!("{src:?}: the checker reports no lexical problem")); + assert_eq!((theirs.code, theirs.line), (*code, Some(*line)), "{src:?}"); + let ours = parse(src, &js_options()).expect_err(src); + let message = LEXICAL.iter().find(|(c, _)| c == code).map(|(_, m)| *m).unwrap(); + assert!(ours.message.contains(message), "{src:?}: {}", ours.message); + assert_eq!(ours.line as usize, *line, "{src:?}"); + } +} + +#[test] +fn the_parser_reads_a_regexp_wherever_the_checker_does() { + // Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a + // regular expression would report it as never closed. The checker reports nothing, and the + // parser reads regular expressions on lines 2 and 3 only. + let src = "a = b / c;\nr = /x/;\nif (a) /y/.test(b);\nz = (1) / 2;\n"; + let tokens = tokenize(src, &js_options()).unwrap(); + let lines: Vec = tokens.iter().filter(|t| matches!(t.kind, TokenKind::RegExp { .. })).map(|t| t.line).collect(); + assert_eq!(lines, [2, 3]); + let report = check("script.jsx", src.as_bytes(), None, &Limits::default()); + assert!(report.problems.iter().all(|p| !LEXICAL.iter().any(|(c, _)| *c == p.code)), "{:?}", report.problems); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index a6f0b02c7..3689d9cae 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -18,6 +18,7 @@ mod dimensions; mod eyedropper_sources; mod find_replace_paras; mod inline_text; +mod js_lexer_vs_scriptcheck; mod known_bugs; mod lossy_overwrite; mod model_based; diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 2a4a5f991..5e7398ce0 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -304,9 +304,13 @@ proptest! { 0..6, ), permissions in prop::option::weighted(0.8, prop_oneof![Just(Value::Null), arb_junk()]), - api in prop_oneof![3 => Just(json!(1)), 1 => arb_junk()], + api in prop_oneof![2 => Just(json!(1)), 1 => Just(json!(2)), 1 => arb_junk()], name in prop_oneof![3 => "[a-z\u{202E}\u{2066}\u{200F} ]{1,64}", 1 => "[a-z\u{202E}\u{2066}\u{0007}\u{200F} ]{1,80}"], - grant in prop_oneof![Just(Value::Null), arb_junk(), Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read"]))], + grant in prop_oneof![ + Just(Value::Null), + arb_junk(), + Just(json!(["doc.read", "doc.write", "state", "effect.render", "export.write", "import.read", "progress"])) + ], ) { let mut m = json!({"id": "fuzz.permissions", "name": name, "kind": kind, "api": api}); if matches!(kind, "exporter" | "importer") { @@ -320,8 +324,7 @@ proptest! { let parsed = catch_quiet(|| vectorcraft_plugins::Manifest::parse(text.as_bytes())); let parsed = parsed.map_err(|msg| TestCaseError::fail(format!("panicked on {text}: {msg}")))?; if let Ok(man) = &parsed { - let implied = vectorcraft_plugins::permissions::Permission::implied(man.kind); - prop_assert!(man.permissions().iter().all(|p| implied.contains(p)), "{}", text); + prop_assert!(man.permissions().iter().all(|p| p.applies(man.kind)), "{}", text); let shown = vectorcraft_plugins::permissions::display_text(&man.name, vectorcraft_plugins::permissions::MAX_SHOWN_NAME); prop_assert!(!shown.chars().any(|c| c.is_control() || matches!(c, '\u{202E}' | '\u{2066}' | '\u{200F}')), "{}", shown); prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME); @@ -338,4 +341,47 @@ proptest! { vectorcraft_plugins::registry::remove("fuzz.permissions"); vectorcraft_plugins::registry::revoke("fuzz.permissions"); } + + /// A plug-in of API level 2 that calls the host imports with hostile arguments (any progress + /// counts, any log level, pointers and lengths in and out of its memory, text that isn't + /// UTF-8) runs to an error or an output, never a panic, and its report stays within the caps. + #[test] + fn hostile_host_import_arguments_never_crash( + calls in prop::collection::vec( + prop_oneof![ + (any::(), any::()).prop_map(|(d, t)| format!("(call $vc_progress (i64.const {d}) (i64.const {t}))")), + Just("(drop (call $vc_cancelled))".to_string()), + (prop_oneof![any::(), -1..5i32], prop_oneof![any::(), 0x1ff00..0x20100i32], prop_oneof![any::(), 0..5000i32]) + .prop_map(|(l, p, n)| format!("(drop (call $vc_log (i32.const {l}) (i32.const {p}) (i32.const {n})))")), + ], + 0..24, + ), + bytes in prop::collection::vec(any::(), 0..64), + effect in any::(), + ) { + use vectorcraft_plugins::host::{LOG_TRUNCATED, MAX_LOG_BYTES, MAX_LOG_LINE, MAX_LOG_LINES}; + let manifest = if effect { + r#"{"id":"fuzz.host","name":"F","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"# + } else { + r#"{"id":"fuzz.host","name":"F","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"# + }; + let text: String = bytes.iter().map(|b| format!("\\{b:02x}")).collect(); + let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::HOST_IMPORTS); + let wasm = wat::parse_str(tpl::with_imports(manifest, &imports, &calls.concat(), r#"{"objects":[]}"#)).unwrap(); + let r = catch_quiet(|| { + let p = vectorcraft_plugins::Plugin::load(&wasm, vectorcraft_plugins::Limits::default())?; + let token = vectorcraft_doc::cancel::CancelToken::new(); + let sink: vectorcraft_doc::cancel::ProgressSink = std::rc::Rc::new(|_, _, _| {}); + Ok::<_, vectorcraft_plugins::Error>(vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(b"{\"objects\":[]}", b"{}", p.limits()))) + }); + let r = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?; + let (out, report) = r.map_err(|e| TestCaseError::fail(format!("refused at load: {e}")))?; + prop_assert!(out.is_ok(), "{:?}", out); + prop_assert!(report.lines.len() <= MAX_LOG_LINES + 1); + prop_assert!(report.lines.iter().all(|l| l.text.chars().count() <= MAX_LOG_LINE)); + prop_assert!(report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum::() <= MAX_LOG_BYTES); + if let Some((done, total)) = report.progress { + prop_assert!(total == 0 || done <= total); + } + } } diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 0004a65d3..eac7b16b2 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -21,6 +21,8 @@ vectorcraft-text = { workspace = true } vectorcraft-metafile = { workspace = true } # Non-native art in Illustrator editing data (a placed PDF's content, kept as a PDF). vectorcraft-pdf = { workspace = true } +# The predefined CMaps that strings of legacy type in CJK fonts are decoded by. +craft-pdf = { workspace = true } # The TIFF writer (previews) and PackBits, shared with raster export. vectorcraft-render = { workspace = true } kurbo = { workspace = true } diff --git a/crates/eps/src/ai_editing.rs b/crates/eps/src/ai_editing.rs index 17f717a11..828145c9c 100644 --- a/crates/eps/src/ai_editing.rs +++ b/crates/eps/src/ai_editing.rs @@ -98,7 +98,8 @@ //! a preview of at most 256 pixels a side between `%AI26_BeginPlacedObjectPreview` and its end //! (`w h rowBytes 32 17 `, then rows of premultiplied ARGB), each in versioned content, `~`, //! `%AI5_EndPlace`. The box is the file's pixels at art space's origin on the canvas; the matrix -//! takes it into art space. In opacity masks and pattern tiles, linked images are embedded. +//! takes it into art space. In opacity masks, pattern tiles and symbols, linked images are +//! embedded. //! - Type: a text object `/AI11Text :` naming its story and frame (`/StoryIndex`, `/FrameIndex`), //! for area type and type on a path with its frame's path (`/Art :` … `; /ConfiningPath ,`), and //! the text document after the document data (`%AI11_BeginTextDocument`, see @@ -107,30 +108,66 @@ //! the alignment, indents, spacing, hyphenation and direction of each paragraph, and the frame: //! point type's point and matrix, area type's frame, rows, columns, gutters and first baseline, //! type on a path's path and span, vertical type, and the frames a threaded story flows through. +//! - Symbols, as the reader reads them ([`crate::import`]'s `.ai` reader, `read.rs`): every symbol +//! of the document defined in the setup after the patterns, in a non-printing block +//! (`%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, the art in the symbol's space with y +//! down, the registration point as `AI13PatternRegistrationType` 1 to 9 from the top left, row by +//! row (VectorCraft's numbering, in the order the scripting reference lists the points), in the +//! art's dictionary, `%AI10_EndSymbol`), each after those its art uses; the symbol +//! list after the palette (`%AI24_BeginSymbolList`, the names in the Symbols panel's order, +//! `%AI24_EndSymbolList`); each instance as `/SymbolInstance :`, `(name) /SymbolRef ,`, the +//! matrix taking the art to art space as `a b c d tx ty /RTransformMatrix`, `;`, then its name +//! and opacity mask and a state block as a group's with its opacity and blending mode (an +//! instance with isolation, knockout or knockout shape of its own is written as its art). The +//! definitions are at most the reader's 4,096. In a symbol's art, an instance's matrix and +//! a gradient's take the art to the canvas's space turned y up about art space's origin there. +//! - Art styles, as the reader reads them (`style.rs`, `read/ops.rs`): a path, compound path or +//! group with live effects is written as its drawn look (`u`, its vector art as its geometry +//! effects leave it, without shadows, glows or feathers, `U`, its transparency, `0 0 Xd`, +//! `6 () XW`), then the object itself on `%_` lines (its points painted +//! with nothing, or with its gradient fill; a group with its members) with its name and opacity +//! mask, then `1 (style) XW`. The styles are written in a non-printing block after the symbols +//! (`%AI9_BeginArtStyles`, each `/KnownStyle :` with its name and a filter tree: a chain of the +//! stack of fills and strokes with the contents among them, the effects and the blend filter). A +//! fill filter fills by the non-zero rule (`0 /Bool (UseEvenOdd)`), so a filled object that fills by +//! the even-odd rule is written as its drawn look. A style is written only when the reader reads it +//! back as the object's appearance. //! //! Lines end in CR LF. Strings are written in UTF-8. Art space is ruler space: y up, its origin at //! the first artboard's top-left corner. On the canvas ([`CANVAS_SIZE`] points square, y down) the //! artboards together are centered; each artboard's `RulerOrigin` is its top-left corner there, and //! `%AI3_TemplateBox` holds the canvas's center in art space. What the form above can't hold is written as plain filled and stroked paths, each such -//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles and art cut to -//! a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, +//! loss a warning: type the text document can't hold (in opacity masks, pattern tiles, symbols and +//! art cut to a clipping object, mirrored, turned or unevenly scaled area type and type on a path, inline graphics, ruby, //! gradient or pattern paint, an appearance of its own) as its outlines and its inline graphics' //! art, the character and paragraph attributes the text document doesn't hold, freeform gradients, gradients //! along or across a stroke and gradients without stops, with more than 256 stops or without extent //! as their average color //! (gradients that dither or mix perceptually are written as classic ones, and gradient stops linked //! to global process colors as their colors), pattern fills whose tiles aren't a grid, whose art reaches past -//! the tile or is painted with patterns as their tiles cut to the shape, clipping groups and clipping layers whose clipping object isn't a path, +//! the tile or is painted with patterns, and pattern fills in symbols, as their tiles cut +//! to the shape, clipping groups and clipping layers whose clipping object isn't a path, //! and the clipping groups inside them, as their art cut to that object (its fill cut too, its //! stroke not; an image in a clipping group of the region it is cut to), images that can't be //! decoded or have more than 33,554,432 pixels as their outlines filled mid gray, images of 16 bits -//! a channel at 8 bits, linked images in opacity masks and pattern tiles as embedded images, -//! symbols and live objects (placed documents included) as their art (symbol art past -//! [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects in all left out), strokes a plain line can't draw +//! a channel at 8 bits, linked images in opacity masks, pattern tiles and symbols as embedded images, +//! symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their +//! own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a +//! clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding +//! type, live objects, instances written as their art, shapes with rounded live corners or strokes +//! written as outlines or brush art, +//! instances that scale their symbol more than 100 times, instances on the layers of a large canvas +//! and instances of +//! symbols past 4,096 as their art (symbol art past [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects +//! in all left out), instances of symbols the document doesn't have as empty groups, symbols' 9-slice +//! guides, export types and dynamic type left out, live objects (placed documents included) as their art, +//! strokes a plain line can't draw //! as their filled outlines, several fills or strokes as a group of a path for each, freeform //! gradient swatches, gradient swatches of more than 256 stops, swatches of patterns written as //! tiles and the placement of pattern swatches that move, turn or scale their pattern, the opacity and blending modes of single fills and strokes, opacity masks on -//! layers and opacity masks nested too deep, and live effects other than geometry effects left out. +//! layers and opacity masks nested too deep, and, on objects written without an art style, live +//! effects other than geometry effects left out; graphic styles, links to them and every art style +//! on a large canvas are left out. //! //! The data streams hold the program compressed with Zstandard after `%AI24_ZStandard_Data`, with //! the version numbers of the newest files users sent ([`private_data`]). @@ -207,11 +244,15 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result 0.0 && board.height() > 0.0) { return Err("the document has no artboard".into()); } + // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. + let scale = canvas_scale(doc); + // The objects that may keep their effects in an art style, as they are before baking (not on + // a large canvas, whose scale the styles' strokes and effects would need). + let styled = writer::styled_objects(doc); + let graphic_styles = writer::has_graphic_styles(doc); // Live geometry effects as their result, as the PDF part draws them. let baked = baked(doc); let doc = baked.as_ref().unwrap_or(doc); - // Artboards that don't fit on the canvas are written on a large canvas, at 1/scale. - let scale = canvas_scale(doc); let reduced = (scale != 1.0).then(|| { let mut d = doc.clone(); crate::import::scale_document(&mut d, 1.0 / scale, true); @@ -227,6 +268,8 @@ pub fn export_ai_editing(doc: &Document, o: &AiEditingOptions) -> Result Result Result], depth: usize, out: &mut Vec) { } out.push(s); } + // The matrix is written to ten decimals. + NodeKind::SymbolInstance { symbol, xf } => { + let xf: Vec = xf.as_coeffs().iter().map(|v| format!("{:.6}", v + 0.0)).collect(); + out.push(format!("{head} instance {symbol:?} [{}] {} {:?}", xf.join(" "), r3(f64::from(n.opacity)), n.blend)); + } other => out.push(format!("{head} {other:?}")), } } @@ -602,8 +607,8 @@ fn hidden_layers_open_hidden() { assert!(text.contains("%AI5_NumLayers: 3\n%AI5_OpenViewLayers: 776\n"), "{text}"); } -/// A 200 × 100 document of a gradient fill, a compound path and an image, and what the data holds -/// as plain paths: type and a symbol instance. +/// A 200 × 100 document of a gradient fill, a compound path, an image and a symbol instance, and +/// what the data holds as plain paths: type. fn unsupported() -> Document { let mut doc = Document::new(200.0, 100.0); let layer = doc.layers[0].id; @@ -671,9 +676,7 @@ fn paths(nodes: &[Arc], out: &mut Vec) { fn art_the_slice_does_not_cover_is_written_as_plain_paths() { let doc = unsupported(); let out = export_ai_editing(&doc, &opts()).unwrap(); - for w in [writer::TYPE, writer::LIVE] { - assert!(out.warnings.iter().any(|x| x == w), "{w}: {:?}", out.warnings); - } + assert_eq!(out.warnings, [writer::TYPE]); let back = read_back(&doc); let layer = back.layers[0].children().unwrap(); let kinds: Vec<&str> = layer @@ -683,12 +686,13 @@ fn art_the_slice_does_not_cover_is_written_as_plain_paths() { NodeKind::Group { .. } => "group", NodeKind::Compound { .. } => "compound", NodeKind::Image(_) => "image", + NodeKind::SymbolInstance { .. } => "instance", _ => "other", }) .collect(); - assert_eq!(kinds, ["path", "compound", "image", "group", "group"]); + assert_eq!(kinds, ["path", "compound", "image", "group", "instance"]); let mut all = vec![]; - paths(&layer[3..], &mut all); + paths(&layer[3..4], &mut all); assert!(all.iter().all(|n| matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }))); // The gradient as a gradient, drawing as it did. let bounds = Rect::new(10.0, 10.0, 30.0, 30.0); @@ -802,11 +806,11 @@ fn a_scaled_nine_slice_symbol_instance_keeps_its_corner_size() { assert!(sizes.contains(&(200, 150)), "the frame is scaled: {sizes:?}"); } -/// Nested symbols whose instances multiply stop at [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of -/// symbol art in all, with a warning. +/// Nested symbols whose instances multiply, written as their art (on a large canvas), stop at +/// [`vectorcraft_doc::MAX_SYMBOL_NODES`] objects of symbol art in all, with a warning. #[test] fn nested_symbol_instances_stop_at_the_symbol_art_limit() { - let mut doc = Document::new(200.0, 100.0); + let mut doc = Document::new(20000.0, 100.0); let layer = doc.layers[0].id; // Empty paths: counted, but written as nothing, so the test stays small; and one filled square. let empty = Arc::new(Node::path(doc.alloc_id(), PathData::new(vec![]), Appearance::default())); @@ -824,7 +828,9 @@ fn nested_symbol_instances_stop_at_the_symbol_art_limit() { assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_LIMIT), "{:?}", out.warnings); // 5,001 objects for the tree and each leaf: 3,198 leaves fit in 16,000,000. let text = String::from_utf8(out.program).unwrap(); - assert_eq!(text.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); + // On the layers; the symbols' definitions come before them, in the setup. + let layers = text.split_once("%%EndSetup").unwrap().1; + assert_eq!(layers.matches("\r\nf\r\n").count(), 3198, "the squares of the leaves written"); } #[test] @@ -900,7 +906,7 @@ fn geometry_effects_in_a_symbol_are_written_as_their_result() { let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "Dot".into(), xf: Affine::translate((50.0, 20.0)) }); doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); - let b = back.layers[0].children().unwrap()[0].visual_bounds().unwrap(); + let b = back.bounds_of_with(&[back.layers[0].children().unwrap()[0].id], true, &back.symbol_boxes()).unwrap(); // The 10 pt square offset by 5 pt, at (50, 20). assert!((b.x0 - 45.0).abs() < 0.1 && (b.x1 - 65.0).abs() < 0.1 && (b.y0 - 15.0).abs() < 0.1 && (b.y1 - 35.0).abs() < 0.1, "{b:?}"); } @@ -925,7 +931,8 @@ fn an_adjusted_image_in_a_symbol_is_written_in_its_adjusted_color() { doc.insert(Some(layer), usize::MAX, instance).unwrap(); let back = read_back(&doc); let mut found = vec![]; - pictures(&back, &back.layers, "", &mut found); + let art: Vec> = back.symbols.iter().map(|s| s.art.clone()).collect(); + pictures(&back, &art, "", &mut found); assert_eq!(found.len(), 1, "the adjusted image is found"); assert!(!found[0].ends_with("rgba [0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 0, 0, 255]"), "the image is adjusted: {found:?}"); } @@ -1226,6 +1233,7 @@ fn looks(nodes: &[Arc], depth: usize, out: &mut Vec) { NodeKind::Group { clip, .. } => format!("group clip={clip}"), NodeKind::Path { clipping, .. } => format!("path clipping={clipping}"), NodeKind::Compound { .. } => "compound".into(), + NodeKind::SymbolInstance { symbol, .. } => format!("instance {symbol:?}"), _ => "other".into(), }; let mut s = format!( @@ -3342,6 +3350,126 @@ fn type_lines(doc: &Document) -> Vec { out } +// ---------- symbols ---------- + +const LEAF: Color = Color::Cmyk { c: 0.6, m: 0.0, y: 0.9, k: 0.1 }; + +fn instance(doc: &mut Document, symbol: &str, xf: Affine) -> Node { + Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +/// A `w` × `h` document whose swatches are one CMYK color (the default swatches' CMYK equivalents +/// change at the fourth decimal when read back, so a second write would differ there). +fn one_swatch_document(w: f64, h: f64) -> Document { + let mut doc = Document::new(w, h); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc +} + +/// A 300 × 200 document of symbols. Its symbols, in the Symbols panel's order: `Spare`, a path no +/// instance uses; `Pair`, a group of a red square and an instance of `Leaf` at half its size; `Leaf`, +/// a group of a square in `LEAF` and an ellipse, its registration point the top left; `Shade`, a +/// group of a rectangle filled with a gradient, a clipping group and an image, its registration +/// point the bottom right; `Solo`, a group of one square, which no instance uses. Layer `Art` +/// holds, bottom first: `Leaf` moved; `Leaf` turned 30° and scaled 1.5 by 0.5, named `Turned`; a +/// hidden one; a locked one; one at 50% in Multiply; `Pair`; a group holding `Leaf`; a clipping +/// group holding `Leaf`; `Leaf` with an opacity mask whose art holds an instance of `Leaf`; `Shade`; +/// and sublayer `Inner` (60%, Screen), which holds `Pair` mirrored. +fn symbols_document() -> Document { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(300.0, 200.0); + doc.swatches = vec![Swatch { name: "Leaf".into(), paint: solid(LEAF), global: false, spot: false }].into(); + doc.swatch_groups = vec![].into(); + doc.layers.clear(); + let art_layer = doc.add_layer(Some("Art")); + // The symbols. + let spare = square(&mut doc, Rect::new(-5.0, -5.0, 5.0, 5.0), Color::gray(0.4)); + let leaf_square = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), LEAF); + let ellipse = + Node::path(doc.alloc_id(), shapes::ellipse(Rect::new(5.0, 10.0, 25.0, 30.0)), Appearance::basic(Paint::None, solid(Color::BLACK), 1.5)); + let leaf = group(&mut doc, vec![leaf_square, ellipse], false); + let red = square(&mut doc, Rect::new(-10.0, -10.0, 10.0, 10.0), Color::rgb(1.0, 0.0, 0.0)); + let small = instance(&mut doc, "Leaf", Affine::translate((15.0, -5.0)) * Affine::scale(0.5)); + let pair = group(&mut doc, vec![red, small], false); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 20.0, 20.0, 40.0))); + let inside = square(&mut doc, Rect::new(0.0, 25.0, 40.0, 35.0), Color::rgb(0.0, 0.0, 1.0)); + let clipped = group(&mut doc, vec![edge, inside], true); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(2, 1, vec![255, 0, 0, 255, 0, 128, 255, 255]).unwrap())); + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 1), Affine::new([10.0, 0.0, 0.0, 10.0, 25.0, 25.0]), None); + let shade = group(&mut doc, vec![shaded, clipped, picture], false); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::gray(0.2)); + let solo = group(&mut doc, vec![lone], false); + for (name, art) in [("Spare", spare), ("Pair", pair), ("Leaf", leaf), ("Shade", shade), ("Solo", solo)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + } + doc.set_symbol_registration("Leaf", vectorcraft_doc::SymbolRegistration(0)); + doc.set_symbol_registration("Shade", vectorcraft_doc::SymbolRegistration(8)); + // The instances. + let mut art = vec![instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0)))]; + let turned = Affine::translate((80.0, 20.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale_non_uniform(1.5, 0.5); + art.push(named(instance(&mut doc, "Leaf", turned), "Turned")); + let mut hidden = instance(&mut doc, "Leaf", Affine::translate((130.0, 10.0))); + hidden.visible = false; + art.push(hidden); + let mut locked = instance(&mut doc, "Leaf", Affine::translate((160.0, 10.0))); + locked.locked = true; + art.push(locked); + art.push(see_through(instance(&mut doc, "Leaf", Affine::translate((190.0, 10.0))), 0.5, BlendMode::Multiply)); + art.push(instance(&mut doc, "Pair", Affine::translate((240.0, 30.0)))); + let grouped = instance(&mut doc, "Leaf", Affine::translate((10.0, 60.0))); + art.push(group(&mut doc, vec![grouped], false)); + let window = clipping(&mut doc, shapes::rectangle(Rect::new(50.0, 60.0, 65.0, 75.0))); + let clipped = instance(&mut doc, "Leaf", Affine::translate((50.0, 60.0))); + art.push(group(&mut doc, vec![window, clipped], true)); + let mask_leaf = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + let mask_art = group(&mut doc, vec![mask_leaf], false); + let mut masked = instance(&mut doc, "Leaf", Affine::translate((90.0, 60.0))); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + art.push(masked); + art.push(instance(&mut doc, "Shade", Affine::translate((130.0, 60.0)))); + for n in art { + doc.insert(Some(art_layer), usize::MAX, n).unwrap(); + } + let inner = doc.add_layer(Some("Inner")); + let inner_node = doc.layers.iter().position(|l| l.id == inner).map(|i| doc.layers.remove(i)).unwrap(); + doc.insert(Some(art_layer), usize::MAX, (*inner_node).clone()).unwrap(); + let mirrored = instance(&mut doc, "Pair", Affine::new([-1.0, 0.0, 0.0, 1.0, 260.0, 120.0])); + doc.insert(Some(inner), usize::MAX, mirrored).unwrap(); + // The sublayer's own transparency, after its last object, an instance. + let sub = doc.node_mut(inner).unwrap(); + (sub.opacity, sub.blend) = (0.6, BlendMode::Screen); + doc +} + +/// Each symbol of `doc` in its order: its name and registration point, then what its art draws, its +/// objects as [`view`] shows them (points, handles, paint, strokes and instances), its images, and +/// its objects' names, transparency and clipping. +fn symbol_looks(doc: &Document) -> Vec { + let mut out = vec![]; + for s in doc.symbols.iter() { + out.push(format!("symbol {} {:?}", s.name, doc.symbol_registration(&s.name))); + let art = std::slice::from_ref(&s.art); + paints_of(doc, art, 1, &mut out); + let mut objects = vec![]; + view(art, 1, &mut objects); + // Images are compared by `pictures`, gradients by `paints_of`. + out.extend(objects.into_iter().filter(|l| !l.contains(" Image(")).map(|l| match l.find(" fill(Gradient(") { + Some(at) => format!("{} fill(gradient)", &l[..at]), + None => l, + })); + pictures(doc, art, &s.name, &mut out); + looks(art, 1, &mut out); + } + out +} + #[test] fn type_comes_back_as_type() { let doc = typed(); @@ -4153,6 +4281,2072 @@ fn type_with_an_opacity_mask_is_written_as_its_outlines() { assert!(top.extras().mask.is_some()); } +#[test] +fn symbols_and_their_instances_are_read_back() { + let doc = symbols_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&lines(&back), &lines(&doc)); + same_lines(&all_looks(&back), &all_looks(&doc)); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + // Each instance draws where it did: the symbols' art and the instances' places agree. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let mut ids = vec![]; + d.walk(|n| { + if matches!(n.kind, NodeKind::SymbolInstance { .. }) { + ids.push(n.id); + } + }); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +#[test] +fn symbols_write_the_same_bytes_again() { + let doc = symbols_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + assert_eq!(private_data(&doc, &opts()).unwrap().0, private_data(&doc, &opts()).unwrap().0); + let again = export_ai_editing(&read_back(&doc), &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&first.program).lines().map(String::from).collect::>()); + assert_eq!(again, first.program); +} + +#[test] +fn symbols_are_written_in_their_forms() { + let p = program_shown(&symbols_document()); + // The definitions in a non-printing block after the patterns, in the order the art first uses + // them (each after those its art uses), the others after them; and the list after the palette + // in the panel's order. + let defs = ["(Leaf)", "(Pair)", "(Shade)", "(Spare)", "(Solo)"].map(|n| p.find(&format!("%AI14_BeginSymbol\n{n}\n0 A\n0 Xw\n0 Ae\n")).unwrap()); + assert!(defs.windows(2).all(|w| w[0] < w[1]), "{p}"); + assert!(p.contains("%AI5_Begin_NonPrinting\nNp\n%AI14_BeginSymbol\n(Leaf)\n"), "{p}"); + let list = + "%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n(Spare)\n(Pair)\n(Leaf)\n(Shade)\n(Solo)\n%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"; + let at = p.find(list).unwrap(); + assert!(p.find("%AI5_EndPalette").unwrap() < at && at < p.find("%AI9_BeginDocumentData").unwrap(), "{p}"); + // A group's registration point in its dictionary, before its state block; a path is written in + // a group of its own. + assert!(p.contains("U\n%_/ArtDictionary :\n%_1 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_9 /Int (AI13PatternRegistrationType) ,\n"), "{p}"); + assert!(p.contains("%AI14_BeginSymbol\n(Spare)\n0 A\n0 Xw\n0 Ae\nu\n"), "{p}"); + // A plain group of one object in a group of its own. + assert!(p.contains("%AI14_BeginSymbol\n(Solo)\n0 A\n0 Xw\n0 Ae\nu\nu\n"), "{p}"); + assert!(p.contains("f\nU\n%_/ArtDictionary :\n%_5 /Int (AI13PatternRegistrationType) ,\n%_;\n%_\n9 () XW\n%AI10_EndSymbol\n"), "{p}"); + // An instance on the page: its matrix takes the art to art space (y up from the artboard's top + // left), then its state block. + assert!(p.contains("/SymbolInstance :\n(Leaf) /SymbolRef ,\n1 0 0 -1 10 -10 /RTransformMatrix\n;\n9 () XW\n"), "{p}"); + // Its name between the two, and its transparency as a group's. + assert!(p.contains("/RTransformMatrix\n;\n%_/ArtDictionary :\n%_/XMLUID : (Turned) ; (AI10_ArtUID) ,\n%_;\n%_\n9 () XW\n"), "{p}"); + assert!(p.contains("1 0 0 -1 190 -10 /RTransformMatrix\n;\n1 0.5 0 2 0 Xy\n0 0 Xd\n6 () XW\n"), "{p}"); + // In a symbol's art, about the canvas point of art space's origin (the template box's center + // is the canvas's). + let t: Vec = p.lines().find_map(|l| l.strip_prefix("%AI3_TemplateBox: ")).unwrap().split(' ').map(|v| v.parse().unwrap()).collect(); + let (ox, oy) = (f64::from(CANVAS_SIZE) / 2.0 - t[0], f64::from(CANVAS_SIZE) / 2.0 + t[1]); + let nested = format!("(Leaf) /SymbolRef ,\n0.5 0 0 -0.5 {} {} /RTransformMatrix\n", ps::num(15.0 - ox), ps::num(oy + 5.0)); + assert!(p.contains(&nested), "{nested}\n{p}"); +} + +/// Instances that the data can't hold as instances are written as their art, with a warning: a +/// stained one, one of a symbol with 9-slice guides, one scaled almost flat, every one on the +/// layers of a large canvas (whose symbols are still defined), one in a pattern's tile, one with a +/// dynamic symbol's appearance changes and one in art cut to a clipping object that isn't a path. +/// A symbol whose art holds an instance of itself is defined once, that instance as art. +#[test] +fn instances_the_data_cant_hold_are_written_as_their_art() { + let leaf = |doc: &mut Document| { + let art = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + }; + let kinds = |d: &Document| -> Vec<&'static str> { + d.layers[0].children().unwrap().iter().map(|n| if matches!(n.kind, NodeKind::SymbolInstance { .. }) { "instance" } else { "art" }).collect() + }; + // Stained. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let mut stained = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + stained.appearance = Appearance::basic(solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, stained).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art", "instance"]); + // 9-slice guides. + doc.set_symbol_nine_slice("Leaf", Some(vectorcraft_doc::SymbolNineSlice::default())); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!((kinds(&back), back.symbols.len()), (vec!["art", "art"], 1)); + // Scaled so flat that its matrix to ten decimals has no area. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let flat = instance(&mut doc, "Leaf", Affine::new([1e-11, 0.0, 0.0, 1.0, 50.0, 10.0])); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, flat).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + assert_eq!(kinds(&read_back(&doc)), ["art"]); + // A large canvas. + let mut doc = Document::new(20000.0, 100.0); + leaf(&mut doc); + let one = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + // The symbol is still defined, at its own size: the canvas's scale doesn't touch its art. + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art"]); + assert_eq!(symbol_looks(&back), symbol_looks(&doc)); + // A pattern a symbol's art and the layers both paint with: the layers' fill keeps it, the + // symbol's art has its tiles. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(100.0, 10.0, 132.0, 42.0)), fill()); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_ART, writer::PATTERNS]); + let back = read_back(&doc); + let on_layer = back.layers[0].children().unwrap()[1].appearance.items[0].paint().clone(); + assert!(matches!(on_layer, Paint::Pattern { .. }), "{on_layer:?}"); + let patch = &back.symbols.iter().find(|s| s.name == "Patch").unwrap().art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + // A pattern's tile: the tile holds the symbol's art. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let tiled = instance(&mut doc, "Leaf", Affine::IDENTITY); + doc.patterns.push(vectorcraft_doc::PatternDef::new("Tiles", vec![Arc::new(tiled)])); + let fill = Appearance::basic(Paint::Pattern { pattern: "Tiles".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), fill); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let tile = &back.patterns.iter().find(|p| p.name == "Tiles").unwrap().art; + assert_eq!(tile.len(), 1); + assert_eq!(instances(&tile[0]), 0, "{:?}", tile[0].kind); + let NodeKind::Group { children, .. } = &tile[0].kind else { panic!("{:?}", tile[0].kind) }; + assert_eq!(paints_of_nodes(&back, children), paints_of_nodes(&back, &[Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF))])); + // A symbol holding an instance of itself: defined once, that instance as its art. + let mut doc = Document::new(200.0, 100.0); + let looped_art = { + let me = instance(&mut doc, "Loop", Affine::translate((20.0, 0.0))); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), LEAF); + group(&mut doc, vec![dot, me], false) + }; + doc.symbols.push(Symbol { dynamic: false, name: "Loop".into(), art: Arc::new(looped_art) }); + let looped = instance(&mut doc, "Loop", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, looped).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let p = program(&doc); + assert_eq!(p.matches("%AI14_BeginSymbol\n(Loop)\n").count(), 1, "{p}"); + assert_eq!(kinds(&read_back(&doc)), ["instance"]); + // Appearance changes of a dynamic symbol's instance. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + doc.symbols[0].dynamic = true; + let mut changed = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + changed.extras_mut().symbol_overrides = + vec![vectorcraft_doc::SymbolOverride { fill: Some(solid(Color::rgb(0.0, 1.0, 0.0))), ..Default::default() }]; + let plain = instance(&mut doc, "Leaf", Affine::translate((80.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, changed).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART, writer::SYMBOL_OPTIONS]); + let back = read_back(&doc); + assert_eq!(kinds(&back), ["art", "instance"]); + let green = back.layers[0].children().unwrap()[0].clone(); + let drawn = paints_of_nodes(&back, &[green]); + assert!(drawn.iter().any(|l| l.contains("rgb 0.000 1.000 0.000")), "the change is drawn: {drawn:?}"); + // Art cut to a clipping object that isn't a path. + let mut doc = Document::new(200.0, 100.0); + leaf(&mut doc); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let frame = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::new([20.0, 0.0, 0.0, 20.0, 40.0, 0.0]), None); + let cut = instance(&mut doc, "Leaf", Affine::translate((45.0, 5.0))); + let clipped = group(&mut doc, vec![frame, cut], true); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, clipped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.layers.iter().map(|l| instances(l)).sum::(), 0); + assert_eq!(back.symbols.len(), 1); +} + +/// Symbols whose art paints with a pattern, directly, through a nested symbol, in an opacity mask +/// and through an inline graphic: their definitions hold the pattern fills as tiles cut to the shape (the reader keeps no +/// symbol whose art paints with a pattern), and their instances are written as their art, whose +/// pattern fills keep the document's placement as the canvas draws them, each with a warning. An +/// instance of a symbol without pattern paint stays an instance. +#[test] +fn symbols_with_pattern_fills_are_defined_with_tiles_and_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + // Tiles that cover the shape, so its tiles cut to it draw its box. + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = || Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let patch = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 16.0, 16.0)), fill()); + doc.symbols.push(Symbol { dynamic: false, name: "Patch".into(), art: Arc::new(patch) }); + let inner = instance(&mut doc, "Patch", Affine::translate((20.0, 0.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let outer = group(&mut doc, vec![edge, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let mask_art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 12.0, 12.0)), fill()); + let mut masked = square(&mut doc, Rect::new(0.0, 0.0, 12.0, 12.0), LEAF); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + doc.symbols.push(Symbol { dynamic: false, name: "Masked".into(), art: Arc::new(masked) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + // Type holding `Patch` as an inline graphic. + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Patch"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let layer = doc.layers[0].id; + // Two instances of `Patch` a part of a tile apart. + for (name, x) in [("Patch", 10.0), ("Patch", 33.0), ("Outer", 60.0), ("Masked", 120.0), ("Plain", 140.0), ("Typed", 10.0)] { + let one = instance(&mut doc, name, Affine::translate((x, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let filled = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(150.0, 10.0, 182.0, 42.0)), fill()); + doc.insert(Some(layer), usize::MAX, filled).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART) && out.warnings.iter().any(|w| w == writer::PATTERNS), "{:?}", out.warnings); + let back = read_back(&doc); + let names: Vec<&str> = back.symbols.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, ["Patch", "Outer", "Masked", "Plain", "Typed"]); + let patch = &back.symbols[0].art; + assert!(matches!(patch.kind, NodeKind::Group { .. }), "the symbol's art is its tiles: {:?}", patch.kind); + assert_eq!(instances(&back.symbols[1].art), 0, "the nested instance is its art"); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, false, true, false, false]); + // The pattern fills of both `Patch` instances and of the layers' path keep one placement. + let placements = |n: &Node| { + let mut out = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| { + for it in m.appearance.items.iter() { + if let Paint::Pattern { xf, .. } = it.paint() { + out.push(xf.as_coeffs().map(r3)); + } + } + }); + out + }; + let layer_fill = placements(&kids[6]); + assert_eq!(layer_fill.len(), 1); + assert_eq!(placements(&kids[0]), layer_fill); + assert_eq!(placements(&kids[1]), layer_fill); + // Each instance draws where it did. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().take(5).map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + same_lines(&boxes(&back), &boxes(&doc)); +} + +/// A symbol whose art paints with a pattern only through a brushed stroke, whose brush's art is +/// filled with a pattern: the definition holds the tiles, and its instance is written as its art. +#[test] +fn symbols_painting_patterns_through_brush_art_are_placed_as_art() { + let mut doc = Document::new(200.0, 100.0); + let dot = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::BLACK)); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![dot]); + dots.tile = Rect::new(0.0, 0.0, 8.0, 8.0); + doc.patterns.push(dots); + let fill = Appearance::basic(Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }, Paint::None, 0.0); + let art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 8.0)), fill); + let brush = vectorcraft_brush::Brush { + name: "Dotted".into(), + kind: vectorcraft_brush::BrushKind::Art(vectorcraft_brush::ArtBrush { + art, + colorization: vectorcraft_brush::Colorization::None, + ..Default::default() + }), + }; + vectorcraft_brush::store(&mut doc, &[brush]); + let mut line = + Node::path(doc.alloc_id(), shapes::line((0.0, 0.0).into(), (60.0, 0.0).into()), Appearance::basic(Paint::None, solid(Color::BLACK), 4.0)); + line.appearance.stroke_mut().unwrap().brush = Some("Dotted".into()); + doc.symbols.push(Symbol { dynamic: false, name: "Brushed".into(), art: Arc::new(line) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Brushed", Affine::translate((10.0, 40.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Brushed"]); + assert_eq!(instances(&back.layers[0]), 0, "the instance is its art"); +} + +/// A symbol whose stroke is written as its outline (an outside stroke): a scaled instance, a scaled +/// instance of a symbol holding it and a turned and mirrored instance are written as their art, +/// which keeps the stroke the canvas draws; a moved instance stays an instance. +#[test] +fn scaled_instances_of_symbols_with_outlined_strokes_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(Paint::None, solid(Color::BLACK), 4.0); + look.stroke_mut().unwrap().align = vectorcraft_doc::StrokeAlign::Outside; + let framed = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + doc.symbols.push(Symbol { dynamic: false, name: "Framed".into(), art: Arc::new(framed) }); + let inner = instance(&mut doc, "Framed", Affine::IDENTITY); + let edge = square(&mut doc, Rect::new(20.0, 0.0, 30.0, 10.0), LEAF); + let outer = group(&mut doc, vec![inner, edge], false); + doc.symbols.push(Symbol { dynamic: false, name: "Outer".into(), art: Arc::new(outer) }); + let layer = doc.layers[0].id; + let turned = Affine::translate((150.0, 20.0)) * Affine::rotate(0.5) * Affine::new([-1.0, 0.0, 0.0, 1.0, 0.0, 0.0]); + for (name, xf) in [ + ("Framed", Affine::translate((40.0, 40.0)) * Affine::scale(2.0)), + ("Outer", Affine::translate((80.0, 10.0)) * Affine::scale(2.0)), + ("Framed", turned), + ("Framed", Affine::translate((170.0, 60.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), ["Framed", "Outer"]); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true]); + // Each draws where it did, with the stroke the canvas draws: 4 pt wide whatever the scale. + let boxes = |d: &Document| -> Vec { + let b = d.symbol_boxes(); + let ids: Vec<_> = d.layers[0].children().unwrap().iter().map(|n| n.id).collect(); + ids.iter().map(|id| format!("{:?}", d.bounds_of_with(&[*id], true, &b).map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)))).collect() + }; + let (got, want) = (boxes(&back), boxes(&doc)); + same_lines(&[got[0].clone(), got[2].clone(), got[3].clone()], &[want[0].clone(), want[2].clone(), want[3].clone()]); + // `Outer` at twice its size from (80, 10): the frame's square is 20 pt from 80 with its 4 pt + // stroke outside, and the plain square is 40 to 60 pt in from it. + assert_eq!(got[1], format!("{:?}", Some([76.0, 6.0, 140.0, 34.0].map(r3)))); +} + +/// Scaled instances of a symbol holding a rounded rectangle with live corners, of one clipped by +/// it, of one holding type, of one holding a blend and of one holding an instance written as its +/// art (of a symbol with 9-slice guides), and instances that scale their symbol more than 100 +/// times (tiny, thin and stroked tiny objects, and a 10 pt square), are written as their art; +/// instances that scale a 10 pt square a thousandth, scale an ellipse unevenly or only move stay +/// instances. +#[test] +fn scaled_instances_of_symbols_drawn_only_at_their_own_size_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let live = vectorcraft_doc::LiveShape::Rectangle { + w: 100.0, + h: 100.0, + radii: [10.0; 4], + kinds: Default::default(), + radii_relative: false, + xf: Affine::IDENTITY, + }; + let mut rounded = Node::new( + doc.alloc_id(), + NodeKind::Path { path: live.to_path(), rule: FillRule::NonZero, live: Some(Box::new(live)), clipping: false, guide: false }, + ); + rounded.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + // The same rounded rectangle clipping a square. + let mut frame = rounded.clone(); + frame.id = doc.alloc_id(); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF); + let clipped = group(&mut doc, vec![frame, inside], true); + doc.symbols.push(Symbol { dynamic: false, name: "Rounded".into(), art: Arc::new(rounded) }); + doc.symbols.push(Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(clipped) }); + // A tiny square with a 1 pt stroke. + let mut dot = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + dot.appearance = Appearance::basic(solid(LEAF), solid(Color::BLACK), 1.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Dot".into(), art: Arc::new(dot) }); + // A blend of two squares 20 pt apart with a step every 10 pt. + let keys = + vec![Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF)), Arc::new(square(&mut doc, Rect::new(20.0, 0.0, 22.0, 2.0), LEAF))]; + let spec = vectorcraft_doc::BlendSpec { spacing: vectorcraft_doc::BlendSpacing::Distance(10.0), ..Default::default() }; + let blend = Node::new(doc.alloc_id(), NodeKind::Blend { children: keys, spec: Box::new(spec) }); + doc.symbols.push(Symbol { dynamic: false, name: "Steps".into(), art: Arc::new(blend) }); + // A symbol holding an instance of one with 9-slice guides. + let frame = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), LEAF)); + let corner = Arc::new(square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK)); + let sliced = Node::group(doc.alloc_id(), vec![frame, corner]); + doc.symbols.push(Symbol { dynamic: false, name: "Sliced".into(), art: Arc::new(sliced) }); + doc.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default())); + let inner = instance(&mut doc, "Sliced", Affine::IDENTITY); + let holder = group(&mut doc, vec![inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Holder".into(), art: Arc::new(holder) }); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 10.0), "Hi", style(10.0, Color::BLACK)); + let typed = text_node(&mut doc, t); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + let tiny = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 0.00001), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Tiny".into(), art: Arc::new(tiny) }); + let thin = square(&mut doc, Rect::new(0.0, 0.0, 0.00001, 1.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Thin".into(), art: Arc::new(thin) }); + let plain = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Plain".into(), art: Arc::new(plain) }); + let oval = vectorcraft_doc::LiveShape::Ellipse { w: 10.0, h: 6.0, pie: (0.0, 360.0), xf: Affine::IDENTITY }; + let mut ellipse = Node::new( + doc.alloc_id(), + NodeKind::Path { path: oval.to_path(), rule: FillRule::NonZero, live: Some(Box::new(oval)), clipping: false, guide: false }, + ); + ellipse.appearance = Appearance::basic(solid(LEAF), Paint::None, 0.0).into(); + doc.symbols.push(Symbol { dynamic: false, name: "Oval".into(), art: Arc::new(ellipse) }); + let layer = doc.layers[0].id; + for (name, xf) in [ + ("Rounded", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 10.0])), + ("Typed", Affine::translate((50.0, 50.0)) * Affine::scale(3.0)), + ("Tiny", Affine::translate((100.0, 50.0)) * Affine::scale(1e6)), + ("Plain", Affine::translate((150.0, 50.0)) * Affine::scale(1e-3)), + ("Plain", Affine::translate((150.0, 60.0)) * Affine::scale(1e3)), + ("Oval", Affine::translate((170.0, 20.0)) * Affine::new([3.0, 0.0, 0.0, 1.0, 0.0, 0.0])), + ("Thin", Affine::translate((100.0, 70.0)) * Affine::new([1e6, 0.0, 0.0, 10.0, 0.0, 0.0])), + ("Clipped", Affine::new([0.3, 0.0, 0.0, 0.1, 10.0, 30.0])), + ("Dot", Affine::translate((120.0, 70.0)) * Affine::scale(1e6)), + ("Steps", Affine::translate((10.0, 80.0)) * Affine::scale(5.0)), + ("Holder", Affine::scale(2.0)), + ("Holder", Affine::translate((5.0, 5.0))), + ] { + let one = instance(&mut doc, name, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + let kinds: Vec = kids.iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect(); + assert_eq!(kinds, [false, false, false, true, false, true, false, false, false, false, false, true]); + let b = kids[6].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 70.0, 110.0, 80.0].map(r3))); + // The tiny square comes back 10 pt across, where the instance put it. + let b = kids[2].visual_bounds().map(|r| [r.x0, r.y0, r.x1, r.y1].map(r3)); + assert_eq!(b, Some([100.0, 50.0, 110.0, 60.0].map(r3))); +} + +/// A symbol whose art is a hidden path with two fills: the canvas draws a symbol's art even when +/// its root is hidden, so the definition's root is written shown and the instance still draws. +#[test] +fn a_symbol_whose_root_is_hidden_still_draws() { + let mut doc = Document::new(200.0, 100.0); + let mut look = Appearance::basic(solid(LEAF), Paint::None, 0.0); + look.items.push(AppearanceItem::Fill(FillLayer::new(solid(Color::BLACK)))); + let mut art = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), look); + art.visible = false; + doc.symbols.push(Symbol { dynamic: false, name: "Hidden".into(), art: Arc::new(art) }); + let layer = doc.layers[0].id; + let one = instance(&mut doc, "Hidden", Affine::translate((20.0, 20.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap(); + assert!(matches!(kids[0].kind, NodeKind::SymbolInstance { .. })); + let mut shown = 0; + vectorcraft_doc::swatches::walk_with_masks(&back.symbols[0].art, &mut |m| { + shown += usize::from(m.visible && matches!(m.kind, NodeKind::Path { .. })) + }); + assert!(back.symbols[0].art.visible && shown > 0, "{:?}", back.symbols[0].art); +} + +/// The number of symbol instances in `n` and its subtree. +fn instances(n: &Node) -> usize { + let mut count = 0; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| count += usize::from(matches!(m.kind, NodeKind::SymbolInstance { .. }))); + count +} + +/// What `nodes` draw ([`paints_of`]). +fn paints_of_nodes(doc: &Document, nodes: &[Arc]) -> Vec { + let mut out = vec![]; + paints_of(doc, nodes, 1, &mut out); + out +} + +/// An instance with isolation, knockout or knockout shape of its own is written as its art, which +/// keeps them as a group's; a plain one beside it stays an instance. +#[test] +fn instances_with_a_groups_transparency_are_written_as_their_art() { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(art) }); + let mut shape = instance(&mut doc, "Leaf", Affine::translate((10.0, 10.0))); + shape.knockout_shape = true; + let mut isolated = instance(&mut doc, "Leaf", Affine::translate((30.0, 10.0))); + isolated.isolate = true; + let mut knocks = instance(&mut doc, "Leaf", Affine::translate((50.0, 10.0))); + knocks.knockout = Knockout::On; + let plain = instance(&mut doc, "Leaf", Affine::translate((70.0, 10.0))); + let mut outer = group(&mut doc, vec![shape, isolated, knocks, plain], false); + outer.knockout = Knockout::On; + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, outer).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + let kids = back.layers[0].children().unwrap()[0].children().unwrap().clone(); + let states: Vec<_> = kids.iter().map(|n| (matches!(n.kind, NodeKind::SymbolInstance { .. }), n.knockout_shape, n.isolate, n.knockout)).collect(); + assert_eq!( + states, + [ + (false, true, false, Knockout::Neutral), + (false, false, true, Knockout::Neutral), + (false, false, false, Knockout::On), + (true, false, false, Knockout::Neutral) + ] + ); + // Each group holds the symbol's art where the instance placed it. + for (i, k) in kids[..3].iter().enumerate() { + let b = k.geometric_bounds().unwrap(); + assert_eq!([b.x0, b.y0, b.x1, b.y1].map(r3), [10.0 + 20.0 * i as f64, 10.0, 20.0 + 20.0 * i as f64, 20.0].map(r3)); + } +} + +/// A symbol's definition is the same wherever its first instance lies: one first met deep in +/// opacity masks keeps its own masks and instances. +#[test] +fn a_symbol_first_used_deep_in_masks_is_defined_whole() { + let mut doc = Document::new(200.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + // `Deep`: a square with an opacity mask, and an instance of `Leaf`. + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::WHITE); + let mut veiled = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::BLACK); + veiled.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true })); + let inner = instance(&mut doc, "Leaf", Affine::translate((5.0, 5.0))); + let deep = group(&mut doc, vec![veiled, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(deep) }); + // Its only instance, in the art of an opacity mask seven masks deep. + let mut art = instance(&mut doc, "Deep", Affine::translate((50.0, 20.0))); + for _ in 0..7 { + let mut holder = square(&mut doc, Rect::new(0.0, 0.0, 100.0, 100.0), Color::gray(0.5)); + holder.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(art), clip: false, invert: false, disabled: false, linked: true })); + art = holder; + } + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, art).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::DEEP_MASKS || w == writer::SYMBOL_ART), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + let deep = &back.symbols.iter().find(|s| s.name == "Deep").unwrap().art; + assert_eq!(instances(deep), 1); + let mut masks = 0; + deep.walk(&mut |n| masks += usize::from(n.extras().mask.is_some())); + assert_eq!(masks, 1); +} + +/// Symbols past the 4,096 the reader keeps are left out, their instances written as their art. +#[test] +fn symbols_past_the_readers_count_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + for i in 0..=crate::import::MAX_SYMBOLS { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + } + // An instance of each, in the panel's order: the last one's symbol is past the count. + let layer = doc.layers[0].id; + for i in 0..=crate::import::MAX_SYMBOLS { + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_COUNT, writer::SYMBOL_ART]); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().all(|s| s.name != format!("S{}", crate::import::MAX_SYMBOLS))); + let live = back.layers[0].children().unwrap().iter().map(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })).collect::>(); + assert_eq!(live.len(), crate::import::MAX_SYMBOLS + 1); + assert!(live[..crate::import::MAX_SYMBOLS].iter().all(|l| *l) && !live[crate::import::MAX_SYMBOLS]); +} + +/// With one place left under the reader's count, a symbol whose type holds an inline graphic with +/// an instance of another symbol (found only as its art is written) takes the last place, and that +/// instance is written as its art: no more definitions are written than the reader keeps. +#[test] +fn a_symbol_found_while_its_holder_is_written_keeps_within_the_readers_count() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + for i in 0..crate::import::MAX_SYMBOLS - 1 { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{i}"), art: Arc::new(art) }); + let one = instance(&mut doc, &format!("S{i}"), Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let style = vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }; + let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "A", style.clone()); + t.runs.push(vectorcraft_doc::TextRun::inline(vectorcraft_doc::InlineArt::new("Inner"), style)); + let typed = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + doc.symbols.push(Symbol { dynamic: false, name: "Typed".into(), art: Arc::new(typed) }); + // The inline graphic's art holds an instance of a symbol of its own. + let deeper = square(&mut doc, Rect::new(0.0, 0.0, 4.0, 4.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Deeper".into(), art: Arc::new(deeper) }); + let nested = instance(&mut doc, "Deeper", Affine::translate((1.0, 1.0))); + let edge = square(&mut doc, Rect::new(0.0, 0.0, 2.0, 2.0), LEAF); + let inner = group(&mut doc, vec![edge, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Inner".into(), art: Arc::new(inner) }); + let one = instance(&mut doc, "Typed", Affine::translate((50.0, 50.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let program = String::from_utf8_lossy(&out.program); + assert_eq!(program.matches("%AI14_BeginSymbol").count(), crate::import::MAX_SYMBOLS); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), crate::import::MAX_SYMBOLS); + assert!(back.symbols.iter().any(|s| s.name == "Typed")); + assert!(matches!(back.layers[0].children().unwrap().last().unwrap().kind, NodeKind::SymbolInstance { .. })); +} + +/// A linked image in a symbol's art is embedded; a dynamic symbol and a symbol's export type are +/// reported left out. +#[test] +fn symbol_options_and_linked_images_in_symbols_are_reported() { + let mut doc = Document::new(200.0, 100.0); + let pixels = png_of(image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(2, 2, image::Rgba([0, 0, 255, 255])))); + let link = vectorcraft_doc::LinkInfo { path: "/tmp/blue.png".into(), ..Default::default() }; + let picture = image_node(&mut doc, ImageBlob::new("image/png", pixels), (2, 2), Affine::scale(5.0), Some(link)); + doc.symbols.push(Symbol { dynamic: true, name: "Picture".into(), art: Arc::new(picture) }); + let one = instance(&mut doc, "Picture", Affine::translate((50.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert_eq!(out.warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); + let p = program_shown(&doc); + assert!(p.contains("%AI5_BeginRaster") && !p.contains("%AI5_BeginPlace"), "{p}"); + let back = read_back(&doc); + let NodeKind::Image(im) = &back.symbols[0].art.kind else { panic!("the symbol's image") }; + assert!(im.link.is_none()); + doc.symbols[0].dynamic = false; + doc.set_symbol_export_type("Picture", vectorcraft_doc::SymbolExportType::Graphic); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_OPTIONS, writer::LINKS_EMBEDDED]); +} + +/// Each of the nine registration points is written 1 to 9 by its place on the grid, row by row +/// from the top left, and read back. +#[test] +fn every_registration_point_is_written_by_its_place() { + for (point, written, read) in (0..9).map(|r| (r, r + 1, r)) { + let mut doc = Document::new(200.0, 100.0); + let art = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Mark".into(), art: Arc::new(art) }); + doc.set_symbol_registration("Mark", vectorcraft_doc::SymbolRegistration(point)); + let p = program_shown(&doc); + assert!(p.contains(&format!("%_{written} /Int (AI13PatternRegistrationType) ,\n")), "{point}: {p}"); + assert_eq!(read_back(&doc).symbol_registration("Mark"), vectorcraft_doc::SymbolRegistration(read), "{point}"); + } +} + +/// Symbols whose art is a group with a state of its own (a name, transparency, an opacity mask, a +/// clipping group) come back with it; the registration point shares the group's dictionary. +#[test] +fn symbols_whose_art_is_a_group_with_a_state_come_back() { + let mut doc = one_swatch_document(200.0, 100.0); + let pair = |doc: &mut Document| { + let a = square(doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + let b = square(doc, Rect::new(5.0, 5.0, 15.0, 15.0), Color::rgb(1.0, 0.0, 0.0)); + vec![a, b] + }; + let kids = pair(&mut doc); + let labeled = named(group(&mut doc, kids, false), "Label"); + let kids = pair(&mut doc); + let faded = see_through(group(&mut doc, kids, false), 0.5, BlendMode::Multiply); + let kids = pair(&mut doc); + let mut masked = group(&mut doc, kids, false); + let mask_art = square(&mut doc, Rect::new(0.0, 0.0, 8.0, 8.0), Color::WHITE); + masked.extras_mut().mask = Some(Box::new(OpacityMask { art: Arc::new(mask_art), clip: true, invert: true, disabled: false, linked: true })); + let edge = clipping(&mut doc, shapes::ellipse(Rect::new(0.0, 0.0, 12.0, 12.0))); + let inside = square(&mut doc, Rect::new(0.0, 0.0, 20.0, 20.0), Color::rgb(0.0, 0.0, 1.0)); + let window = group(&mut doc, vec![edge, inside], true); + let lone = square(&mut doc, Rect::new(0.0, 0.0, 6.0, 6.0), Color::gray(0.3)); + let lone_named = named(group(&mut doc, vec![lone], false), "Lone"); + for (name, art) in [("Labeled", labeled), ("Faded", faded), ("Masked", masked), ("Window", window), ("Lone", lone_named)] { + doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); + let one = instance(&mut doc, name, Affine::translate((20.0, 20.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + doc.set_symbol_registration("Labeled", vectorcraft_doc::SymbolRegistration(2)); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); + same_lines(&all_looks(&back), &all_looks(&doc)); + assert_eq!(export_ai_editing(&back, &opts()).unwrap().program, out.program); + let p = program_shown(&doc); + // The name and the registration point in one dictionary, the group not wrapped again. + assert!(p.contains("%AI14_BeginSymbol\n(Labeled)\n0 A\n0 Xw\n0 Ae\nu\n0.6 0 0.9 0.1 k\n"), "{p}"); + assert!(p.contains("%_/ArtDictionary :\n%_3 /Int (AI13PatternRegistrationType) ,\n%_/XMLUID : (Label) ; (AI10_ArtUID) ,\n%_;\n"), "{p}"); +} + +/// The names of symbols in every form a string takes: parentheses, a backslash, a slash, text +/// outside ASCII, an empty name (which the reader names `Symbol`); two symbols of one name keep the +/// first; an instance of a symbol the document doesn't have is an empty group, with a warning that +/// says so. +#[test] +fn symbol_names_and_missing_symbols() { + let mut doc = Document::new(200.0, 100.0); + let names = ["Paren (a)", "Back\\slash", "Path/To", "Hoja ñ 葉", "", "Twice"]; + for (i, name) in names.iter().chain(["Twice"].iter()).enumerate() { + let art = square(&mut doc, Rect::new(0.0, 0.0, 4.0 + i as f64, 4.0), Color::gray(0.5)); + doc.symbols.push(Symbol { dynamic: false, name: (*name).into(), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for name in names { + let one = instance(&mut doc, name, Affine::translate((10.0, 10.0))); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let back = read_back(&doc); + let names = names.map(|n| if n.is_empty() { "Symbol" } else { n }); + assert_eq!(back.symbols.iter().map(|s| s.name.as_str()).collect::>(), names); + let refs: Vec = back.layers[0] + .children() + .unwrap() + .iter() + .map(|n| match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => symbol.clone(), + other => panic!("{other:?}"), + }) + .collect(); + assert_eq!(refs, names); + // The first symbol of a name is the one defined. + let twice = &back.symbols.iter().find(|s| s.name == "Twice").unwrap().art; + assert_eq!(paints_of_nodes(&back, std::slice::from_ref(twice)), paints_of_nodes(&doc, std::slice::from_ref(&doc.symbols[5].art))); + // A missing symbol. + let lost = instance(&mut doc, "Gone", Affine::translate((40.0, 10.0))); + doc.insert(Some(layer), usize::MAX, lost).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::MISSING_SYMBOL]); + let back = read_back(&doc); + let last = back.layers[0].children().unwrap().last().unwrap().clone(); + assert!(matches!(&last.kind, NodeKind::Group { children, clip: false } if children.is_empty()), "{:?}", last.kind); +} + +/// On a large canvas, a symbol's art holding a gradient and a nested instance is defined at its own +/// size and comes back as it was. +#[test] +fn a_symbol_with_a_gradient_and_an_instance_on_a_large_canvas_comes_back() { + use vectorcraft_color::gradient::GradientGeom; + let mut doc = Document::new(20000.0, 100.0); + let leaf = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "Leaf".into(), art: Arc::new(leaf) }); + let geom = GradientGeom { start: (0.0, 0.0).into(), end: (40.0, 10.0).into(), aspect: 1.0, focal: None }; + let shaded = painted( + &mut doc, + shapes::rectangle(Rect::new(0.0, 0.0, 40.0, 20.0)), + vec![AppearanceItem::Fill(FillLayer::new(gradient_paint(sunset(), Some(geom), None)))], + ); + let nested = instance(&mut doc, "Leaf", Affine::translate((25.0, 5.0)) * Affine::rotate(0.4)); + let both = group(&mut doc, vec![shaded, nested], false); + doc.symbols.push(Symbol { dynamic: false, name: "Both".into(), art: Arc::new(both) }); + let one = instance(&mut doc, "Both", Affine::translate((100.0, 10.0))); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::SYMBOL_ART]); + let back = read_back(&doc); + same_lines(&symbol_looks(&back), &symbol_looks(&doc)); +} + +/// Instance matrices of every kind (turned, sheared, mirrored, scaled near the smallest area the +/// data keeps), nested in one another, are read back as written, and written again as the same +/// bytes. The matrices come from a fixed seed. +#[test] +fn generated_instance_matrices_come_back() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed >> 11) as f64 / (1u64 << 53) as f64 + }; + for round in 0..40 { + let mut doc = one_swatch_document(300.0, 300.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let matrix = |next: &mut dyn FnMut() -> f64| { + // Four nested matrices together scale by at most 64, within the 100 an instance may. + let scale = 10f64.powf(next() * 3.0 - 3.0); + Affine::new([ + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + (next() * 4.0 - 2.0) * scale, + next() * 300.0, + next() * 300.0, + ]) + }; + // A chain of symbols, each holding an instance of the one before. + for level in 1..4 { + let xf = matrix(&mut next); + let inner = instance(&mut doc, &format!("S{}", level - 1), xf); + let dot = square(&mut doc, Rect::new(0.0, 0.0, 3.0, 3.0), Color::gray(0.4)); + let art = group(&mut doc, vec![dot, inner], false); + doc.symbols.push(Symbol { dynamic: false, name: format!("S{level}"), art: Arc::new(art) }); + } + let layer = doc.layers[0].id; + for _ in 0..6 { + let pick = format!("S{}", (next() * 4.0) as usize % 4); + let xf = matrix(&mut next); + let one = instance(&mut doc, &pick, xf); + doc.insert(Some(layer), usize::MAX, one).unwrap(); + } + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + // Each instance written as one comes back with its matrix to ten decimals. + let written = |d: &Document| { + let mut all = vec![]; + for n in d.layers[0].children().unwrap().iter().chain(d.symbols.iter().map(|s| &s.art)) { + n.walk(&mut |m| { + if let NodeKind::SymbolInstance { symbol, xf } = &m.kind { + all.push((symbol.clone(), *xf)); + } + }); + } + all + }; + assert!(out.warnings.is_empty(), "round {round}: {:?}", out.warnings); + let (got, want) = (written(&back), written(&doc)); + assert_eq!(got.len(), want.len(), "round {round}"); + for (g, w) in got.iter().zip(&want) { + assert_eq!(g.0, w.0, "round {round}"); + for (a, b) in g.1.as_coeffs().iter().zip(w.1.as_coeffs()) { + assert!((a - b).abs() <= 1e-9 * b.abs().max(1.0), "round {round}: {:?} {:?}", g.1, w.1); + } + } + let again = export_ai_editing(&back, &opts()).unwrap().program; + same_lines(&shown(&again).lines().map(String::from).collect::>(), &shown(&out.program).lines().map(String::from).collect::>()); + assert_eq!(again, out.program, "round {round}"); + } + // About the smallest area the data keeps: an instance whose matrix keeps it stays one, one + // whose matrix (to ten decimals) loses it is written as its art. + for (xf, live) in [ + (Affine::new([1e-6, 0.0, 0.0, 1.1e-6, 50.0, 10.0]), true), + (Affine::new([1e-6, 0.0, 0.0, 0.9e-6, 50.0, 10.0]), false), + (Affine::new([1.0, 0.4, 2.5, 1.0000000001, 50.0, 10.0]), true), + (Affine::new([4e-11, 0.0, 0.0, 1e6, 50.0, 10.0]), false), + ] { + let mut doc = Document::new(200.0, 100.0); + let base = square(&mut doc, Rect::new(0.0, 0.0, 10.0, 10.0), LEAF); + doc.symbols.push(Symbol { dynamic: false, name: "S0".into(), art: Arc::new(base) }); + let one = instance(&mut doc, "S0", xf); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + let back = read_back(&doc); + assert_eq!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. }), live, "{xf:?}"); + } +} + +/// Type in a symbol's art is written as its outlines (the reader reads no type there), so the +/// symbol and its instance come back; type on the layers stays type. +#[test] +fn type_in_a_symbols_art_is_written_as_its_outlines() { + let mut doc = Document::new(200.0, 100.0); + let label = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(0.0, 20.0), "Tag", style(18.0, Color::BLACK)); + let label = text_node(&mut doc, label); + doc.symbols.push(Symbol { dynamic: false, name: "Tag".into(), art: Arc::new(label) }); + let one = instance(&mut doc, "Tag", Affine::translate((20.0, 20.0))); + let plain = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "Plain", style(18.0, Color::BLACK)); + let plain = text_node(&mut doc, plain); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, one).unwrap(); + doc.insert(Some(layer), usize::MAX, plain).unwrap(); + assert_eq!(export_ai_editing(&doc, &opts()).unwrap().warnings, [writer::TYPE]); + assert_eq!(program(&doc).matches("/AI11Text :").count(), 1); + let back = read_back(&doc); + assert_eq!(back.symbols.len(), 1); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::SymbolInstance { .. })); +} + +// ---------- art styles ---------- + +/// Effect `id` with its defaults and `params` over them. +fn fx(id: &str, params: serde_json::Value) -> vectorcraft_doc::Effect { + vectorcraft_effects::new_effect(id, ¶ms).unwrap() +} + +/// A path `name` over `r` filled with `fill` and stroked 2 pt in `stroke` (none when `Paint::None`), +/// with `effects`. +fn styled_rect(doc: &mut Document, name: &str, r: Rect, fill: Paint, stroke: Paint, effects: Vec) -> Node { + let mut ap = Appearance::basic(fill, stroke, 2.0); + ap.effects = effects; + named(Node::path(doc.alloc_id(), shapes::rectangle(r), ap), name) +} + +/// A 400 × 300 document whose objects keep their live effects as art styles: a drop shadow in a +/// color and one by darkness, an inner and an outer glow in that order, Feather, Round Corners, +/// Scribble, Convert to Shape, Offset Path, Outline Stroke on an open path, a drop shadow on a +/// gradient fill and on a group with transparency, a hidden effect, Pathfinder › Add on a group, a +/// hidden locked object, an opacity mask, an object linked to a graphic style; and a graphic style +/// of its own with two fills, a stroke, item transparency and effects. +fn styles_document() -> Document { + let mut doc = Document::new(400.0, 300.0); + let layer = doc.layers[0].id; + let add = |doc: &mut Document, n: Node| { + doc.insert(Some(layer), usize::MAX, n).unwrap(); + }; + let red = solid(Color::rgb(0.9, 0.2, 0.1)); + let blue = solid(Color::rgb(0.1, 0.3, 0.8)); + let shadow = fx("stylize.dropShadow", serde_json::json!({"x": 6.0, "y": 4.0, "blur": 8.0, "opacity": 60.0, "color": "#203040"})); + let n = styled_rect(&mut doc, "Shadowed", Rect::new(10.0, 10.0, 60.0, 50.0), red.clone(), blue.clone(), vec![shadow.clone()]); + add(&mut doc, n); + let dark = fx("stylize.dropShadow", serde_json::json!({"shade": "darkness", "darkness": 40.0, "mode": "darken"})); + let n = styled_rect(&mut doc, "Darkened", Rect::new(70.0, 10.0, 120.0, 50.0), blue.clone(), Paint::None, vec![dark]); + add(&mut doc, n); + let glows = vec![ + fx("stylize.innerGlow", serde_json::json!({"source": "center", "blur": 3.0})), + fx("stylize.outerGlow", serde_json::json!({"blur": 9.0, "opacity": 50.0})), + ]; + let n = styled_rect(&mut doc, "Glowing", Rect::new(130.0, 10.0, 180.0, 50.0), red.clone(), Paint::None, glows); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Soft", + Rect::new(190.0, 10.0, 240.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({"radius": 7.0}))], + ); + add(&mut doc, n); + let round = fx("stylize.roundCorners", serde_json::json!({"radius": 9.0})); + let n = styled_rect(&mut doc, "Rounded", Rect::new(250.0, 10.0, 300.0, 50.0), red.clone(), blue.clone(), vec![round]); + add(&mut doc, n); + let n = styled_rect( + &mut doc, + "Scribbled", + Rect::new(310.0, 10.0, 360.0, 50.0), + blue.clone(), + Paint::None, + vec![fx("stylize.scribble", serde_json::json!({}))], + ); + add(&mut doc, n); + let shape = fx("convertToShape.roundedRectangle", serde_json::json!({"relative": true, "extraW": 12.0, "extraH": 8.0, "radius": 5.0})); + let n = styled_rect(&mut doc, "Shaped", Rect::new(10.0, 70.0, 60.0, 110.0), red.clone(), Paint::None, vec![shape]); + add(&mut doc, n); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 4.0, "joins": "miter", "miterLimit": 6.0})); + let n = styled_rect(&mut doc, "Offset", Rect::new(70.0, 70.0, 120.0, 110.0), blue.clone(), red.clone(), vec![offset]); + add(&mut doc, n); + let mut open = PathData::from_bezpath(&kurbo::BezPath::from_vec(vec![ + kurbo::PathEl::MoveTo((130.0, 110.0).into()), + kurbo::PathEl::LineTo((180.0, 70.0).into()), + ])); + open.subpaths.iter_mut().for_each(|sp| sp.closed = false); + let mut ap = Appearance::basic(Paint::None, red.clone(), 6.0); + ap.effects = vec![fx("path.outlineStroke", serde_json::json!({}))]; + let n = named(Node::path(doc.alloc_id(), open, ap), "Outlined"); + add(&mut doc, n); + let ramp = Gradient { + kind: GradientKind::Linear, + stops: vec![GradientStop::new(0.0, Color::rgb(1.0, 1.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.6, 0.2))], + ..Gradient::default() + }; + let graded = Paint::Gradient(Box::new(GradientPaint::new(ramp))); + let n = styled_rect(&mut doc, "Graded", Rect::new(190.0, 70.0, 240.0, 110.0), graded, Paint::None, vec![shadow.clone()]); + add(&mut doc, n); + let mut hidden_fx = fx("stylize.outerGlow", serde_json::json!({})); + hidden_fx.visible = false; + let mut quiet = styled_rect(&mut doc, "Quiet", Rect::new(250.0, 70.0, 300.0, 110.0), blue.clone(), Paint::None, vec![hidden_fx]); + quiet.visible = false; + quiet.locked = true; + add(&mut doc, quiet); + // A group with a shadow, transparency and a mask; a group merged by Pathfinder › Add. + let a = square(&mut doc, Rect::new(10.0, 130.0, 50.0, 170.0), Color::rgb(0.2, 0.6, 0.3)); + let b = square(&mut doc, Rect::new(30.0, 150.0, 70.0, 190.0), Color::rgb(0.6, 0.2, 0.5)); + let mut shaded = see_through(group(&mut doc, vec![a, b], false), 0.5, BlendMode::Multiply); + shaded.appearance = Appearance { effects: vec![shadow.clone()], ..Appearance::default() }.into(); + let mask_art = square(&mut doc, Rect::new(10.0, 130.0, 70.0, 160.0), Color::gray(0.0)); + shaded.extras_mut().mask = Some(OpacityMask { art: Arc::new(mask_art), clip: true, invert: false, disabled: false, linked: true }.into()); + add(&mut doc, named(shaded, "Shaded group")); + let a = square(&mut doc, Rect::new(90.0, 130.0, 130.0, 170.0), Color::rgb(0.9, 0.6, 0.1)); + let b = square(&mut doc, Rect::new(110.0, 150.0, 150.0, 190.0), Color::rgb(0.9, 0.6, 0.1)); + let mut merged = group(&mut doc, vec![a, b], false); + merged.appearance = Appearance { effects: vec![fx("pathfinder.add", serde_json::json!({}))], ..Appearance::default() }.into(); + add(&mut doc, named(merged, "Merged")); + doc +} + +/// Each object's name, kind, transparency, state, mask and appearance, and whether it is linked to a +/// graphic style (by name), compared with [`writer::same_appearance`] apart. +fn style_view(doc: &Document) -> Vec<(String, Appearance)> { + let mut out = vec![]; + for n in doc.layers.iter().flat_map(|l| l.children().cloned().unwrap_or_default()) { + let kind = match &n.kind { + NodeKind::Group { .. } => "group", + NodeKind::Path { .. } => "path", + NodeKind::Compound { .. } => "compound", + _ => "other", + }; + let link = n.extras().graphic_style.and_then(|id| doc.graphic_style_by_id(id)).map(|g| g.name.clone()); + let head = format!( + "{:?} {kind} {} {:?} isolate={} {:?} shown={} locked={} mask={} link={link:?} members={}", + n.name, + r3(f64::from(n.opacity)), + n.blend, + n.isolate, + n.knockout, + n.visible, + n.locked, + n.extras().mask.is_some(), + n.children().map_or(0, Vec::len) + ); + out.push((head, (*n.appearance).clone())); + } + out +} + +/// `ap` with its gradients' stops and kind only: their placement is the slice of gradients' (a +/// gradient comes back placed on the object, named after its definition). +fn unplaced(ap: &Appearance) -> Appearance { + let mut ap = ap.clone(); + for item in ap.items.iter_mut() { + let paint = match item { + AppearanceItem::Fill(f) => &mut f.paint, + AppearanceItem::Stroke(s) => &mut s.paint, + }; + if let Paint::Gradient(g) = paint { + (g.geom, g.swatch, g.angle) = (None, None, 0.0); + } + } + ap +} + +fn same_styles(got: &Document, want: &Document) { + let (g, w) = (style_view(got), style_view(want)); + same_lines(&g.iter().map(|(h, _)| h.clone()).collect::>(), &w.iter().map(|(h, _)| h.clone()).collect::>()); + for ((h, a), (_, b)) in g.iter().zip(&w) { + assert!(writer::same_appearance(&unplaced(a), &unplaced(b)), "{h}:\n got {a:?}\n want {b:?}"); + } + same_lines(&outlines(got), &outlines(want)); +} + +/// The points of `doc`'s objects and of their opacity masks, each path's fill rule and each +/// object's nesting, to the written precision. +fn outlines(doc: &Document) -> Vec { + fn walk(n: &Node, depth: usize, out: &mut Vec) { + let mut points = |path: &PathData, rule: Option| { + let mut s = format!("{depth} {:?} {rule:?}", n.name); + for sp in &path.subpaths { + s += &format!(" [closed={}", sp.closed); + for a in &sp.anchors { + s += &format!(" {:.2},{:.2}", a.p.x + 0.0, a.p.y + 0.0); + } + s += "]"; + } + out.push(s); + }; + match &n.kind { + NodeKind::Path { path, rule, .. } => points(path, Some(*rule)), + NodeKind::Compound { rule, children } => { + for c in children { + points(c.path_data().unwrap(), Some(*rule)); + } + } + NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => { + out.push(format!("{depth} {:?} container", n.name)); + for c in children { + walk(c, depth + 1, out); + } + } + other => out.push(format!("{depth} {other:?}")), + } + if let Some(mask) = &n.extras().mask { + out.push(format!("{depth} mask clip={} invert={} disabled={} linked={}", mask.clip, mask.invert, mask.disabled, mask.linked)); + // The reader holds a mask's art in a group. + match &mask.art.kind { + NodeKind::Group { children, .. } if children.len() == 1 => walk(&children[0], depth + 1, out), + _ => walk(&mask.art, depth + 1, out), + } + } + } + let mut out = vec![]; + for l in doc.layers.iter() { + walk(l, 0, &mut out); + } + 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_are_read_back() { + let doc = styles_document(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + // A group's members come back as they were. + let members = |d: &Document, name: &str| { + let mut out = vec![]; + let g = d.layers[0].children().unwrap().iter().find(|n| n.name.as_deref() == Some(name)).unwrap().clone(); + view(g.children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back, "Shaded group"), &members(&doc, "Shaded group")); + same_lines(&members(&back, "Merged"), &members(&doc, "Merged")); +} + +#[test] +fn art_styles_are_written_in_their_forms() { + let p = program_shown(&styles_document()); + // The styles in a non-printing block after the symbols' and before the palette. + let styles = p.find("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n/KnownStyle :\n(Anon 1) /Name ,\n/ActiveStyle :\n").unwrap(); + assert!(styles < p.find("%AI5_BeginPalette").unwrap(), "{p}"); + assert!(!p.contains("%AI9_BeginArtStyleList"), "{p}"); + // A drop shadow on the object, after its stack and before its transparency. + assert!( + p.contains( + "/BasicFilter :\n(Adobe Drop Shadow) 1 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n1 /Int (blnd) ,\n0.6 /Real (opac) ,\n\ + 4 /Real (vert) ,\n6 /Real (horz) ,\n4 /Real (blur) ,\n/FillStyle : 0 O\n" + ), + "{p}" + ); + // The drawn look, its state block with the object's transparency, the object on `%_` lines + // painted with nothing, its name, and its style. + assert!(p.contains("U\n0 1 0 2 0 Xy\n0 0 Xd\n6 () XW\n%_0 XR\n%_0 1 0 0 0 Xy\n%_10 -10 m\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Shadowed) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon 1) XW\n"), "{p}"); + assert!(p.contains("%_n\n%_/ArtDictionary :\n%_/XMLUID : (Offset) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // A group's members on `%_` lines, then its name and style. + assert!(p.contains("6 () XW\n%_u\n"), "{p}"); + assert!(p.contains("%_U\n%_/ArtDictionary :\n%_/XMLUID : (Merged) ; (AI10_ArtUID) ,\n%_;\n%_\n1 (Anon "), "{p}"); + // Objects of the same appearance share their style. + let two = { + let mut doc = Document::new(200.0, 100.0); + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + for x in [10.0, 60.0] { + let n = styled_rect(&mut doc, "Twin", Rect::new(x, 10.0, x + 30.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + } + program(&doc) + }; + assert_eq!((two.matches("/KnownStyle :").count(), two.matches("1 (Anon 1) XW\n").count()), (1, 2), "{two}"); + assert!(!two.contains("%AI9_BeginArtStyleList"), "{two}"); +} + +/// Objects whose appearance the reader wouldn't read with their style are written as their +/// drawn look, with the warnings they were written with before; and a document without effects has +/// no art styles. +#[test] +fn appearances_the_reader_wouldnt_read_are_their_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + // Two fills and an effect: the reader keeps such an object's drawn look. + let mut two = styled_rect(&mut doc, "Two fills", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow.clone()]); + let mut ap = (*two.appearance).clone(); + ap.items.push(AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(solid(Color::WHITE)) })); + two.appearance = ap.into(); + doc.insert(Some(layer), usize::MAX, two).unwrap(); + // A warp on a path: the reader keeps a path's drawn look for it. + let warped = styled_rect( + &mut doc, + "Warped", + Rect::new(50.0, 10.0, 80.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("warp.arc", serde_json::json!({}))], + ); + doc.insert(Some(layer), usize::MAX, warped).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = String::from_utf8(out.program).unwrap(); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.lines().any(|l| l.starts_with("1 (")), "{p}"); + let back = read_back(&doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| n.appearance.effects.is_empty())); + // Nothing styled: no art styles. + assert!(!program(&sample()).contains("ArtStyle")); +} + +/// A compound shape draws as one area, which an art style's object wouldn't keep: it and a group +/// holding one are written as their drawn look, with a warning. +#[test] +fn compound_shapes_are_not_written_with_an_art_style() { + let shadow = || fx("stylize.dropShadow", serde_json::json!({})); + let shape = |doc: &mut Document| { + let a = square(doc, Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK); + let b = square(doc, Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK); + let mut g = group(doc, vec![a, b], false); + g.extras_mut().compound_shape = true; + g + }; + let mut own = Document::new(200.0, 100.0); + let mut n = shape(&mut own); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = own.layers[0].id; + own.insert(Some(layer), usize::MAX, n).unwrap(); + let mut nested = Document::new(200.0, 100.0); + let member = shape(&mut nested); + let mut n = group(&mut nested, vec![member], false); + n.appearance = Appearance { effects: vec![shadow()], ..Appearance::default() }.into(); + let layer = nested.layers[0].id; + nested.insert(Some(layer), usize::MAX, n).unwrap(); + for doc in [own, nested] { + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("1 (Anon 1) XW")); + } +} + +/// A member of a group whose Transform effect moves it is written where baking put it, not with +/// its own effects where it was. +#[test] +fn a_styled_member_of_a_moved_group_stays_where_it_draws() { + fn left_of_paths(n: &Node) -> f64 { + let own = n.path_data().map_or(f64::INFINITY, |p| kurbo::Shape::bounding_box(&p.to_bezpath()).x0); + n.children().map_or(own, |c| c.iter().map(|m| left_of_paths(m)).fold(own, f64::min)) + } + let mut doc = Document::new(300.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let member = styled_rect(&mut doc, "Member", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let mut moved = group(&mut doc, vec![member], false); + moved.appearance = Appearance { effects: vec![fx("distort.transform", serde_json::json!({"moveH": 70.0}))], ..Appearance::default() }.into(); + doc.insert(Some(layer), usize::MAX, moved).unwrap(); + let back = read_back(&doc); + let left = left_of_paths(&back.layers[0]); + assert!((left - 80.0).abs() < 1e-3, "the art starts at x = {left}"); +} + +/// A hidden, locked styled group keeps its state when its members are shown and unlocked. +#[test] +fn a_styled_group_keeps_its_state_after_members_in_another() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let member = square(&mut doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut shaded = group(&mut doc, vec![member], false); + shaded.appearance = Appearance { effects: vec![fx("stylize.dropShadow", serde_json::json!({}))], ..Appearance::default() }.into(); + shaded.visible = false; + shaded.locked = true; + doc.insert(Some(layer), usize::MAX, shaded).unwrap(); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(matches!(n.kind, NodeKind::Group { .. })); + assert_eq!(n.appearance.effects.len(), 1); + assert!(!n.visible && n.locked, "visible {} locked {}", n.visible, n.locked); +} + +/// Effects in a styled object's opacity mask are not its style's: leaving them out warns. +#[test] +fn effects_in_a_styled_objects_mask_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let layer = doc.layers[0].id; + let shadow = fx("stylize.dropShadow", serde_json::json!({})); + let mut n = styled_rect(&mut doc, "Masked", Rect::new(10.0, 10.0, 40.0, 40.0), solid(Color::BLACK), Paint::None, vec![shadow]); + let blurred = styled_rect( + &mut doc, + "Mask art", + Rect::new(10.0, 10.0, 40.0, 25.0), + solid(Color::gray(0.0)), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(blurred), clip: true, invert: false, disabled: false, linked: true }.into()); + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(shown(&out.program).contains("\n1 (Anon 1) XW\n")); +} + +/// On a large canvas no art style is written (the reader scales the styles of a large canvas): +/// objects with effects are written as their vector art with their geometry effects applied, with a +/// warning. +#[test] +fn a_large_canvas_writes_no_art_styles() { + let mut doc = Document::new(20000.0, 100.0); + let n = styled_rect( + &mut doc, + "Far", + Rect::new(10.0, 10.0, 40.0, 40.0), + solid(Color::BLACK), + Paint::None, + vec![fx("stylize.feather", serde_json::json!({}))], + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES), "{:?}", out.warnings); + assert!(!String::from_utf8(out.program).unwrap().contains("ArtStyle")); +} + +#[test] +fn art_styles_write_the_same_bytes_again() { + let doc = styles_document(); + let first = export_ai_editing(&doc, &opts()).unwrap(); + assert!(shown(&first.program).contains("\n1 (Anon 1) XW\n")); + assert_eq!(first.program, export_ai_editing(&doc, &opts()).unwrap().program); + 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") +} + +/// `n` on the first layer of `doc`. +fn put(doc: &mut Document, n: Node) { + let layer = doc.layers[0].id; + doc.insert(Some(layer), usize::MAX, n).unwrap(); +} + +fn drop_shadow() -> vectorcraft_doc::Effect { + fx("stylize.dropShadow", serde_json::json!({})) +} + +fn black_fill() -> Paint { + solid(Color::BLACK) +} + +/// An even-odd compound path of a square inside a square, with `ap`. +fn ring(doc: &mut Document, ap: Appearance) -> Node { + let outer = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(70.0, 10.0, 110.0, 50.0)), Appearance::default()); + let inner = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(80.0, 20.0, 100.0, 40.0)), Appearance::default()); + let mut n = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![Arc::new(outer), Arc::new(inner)], rule: FillRule::EvenOdd }); + n.appearance = ap.into(); + n +} + +/// A path filled black with no stroke and `effects`. +fn plain_rect(doc: &mut Document, name: &str, effects: Vec) -> Node { + styled_rect(doc, name, Rect::new(10.0, 10.0, 50.0, 40.0), black_fill(), Paint::None, effects) +} + +/// A 200 × 100 document's first object as it reads back from a document of only `n`. +fn read_one(make: impl FnOnce(&mut Document) -> Node) -> (Arc, Vec, String) { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let back = read_back(&doc); + (back.layers[0].children().unwrap()[0].clone(), out.warnings, shown(&out.program)) +} + +/// A filled path or compound path with live effects that fills by the even-odd rule is written as its +/// drawn look with a warning, since a style's fill fills by the non-zero rule. A stroke-only object +/// keeps its style and its own fill rule. +#[test] +fn a_filled_even_odd_object_is_written_as_its_drawn_look() { + let mut doc = Document::new(300.0, 100.0); + let mut odd = plain_rect(&mut doc, "Odd", vec![drop_shadow()]); + if let NodeKind::Path { rule, .. } = &mut odd.kind { + *rule = FillRule::EvenOdd; + } + put(&mut doc, odd); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }; + let n = named(ring(&mut doc, ap), "Ring"); + put(&mut doc, n); + let n = plain_rect(&mut doc, "Even", vec![drop_shadow()]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert_eq!((p.matches("/KnownStyle :").count(), p.matches("\n1 (Anon 1) XW\n").count()), (1, 1), "{p}"); + assert!(!p.contains("1 /Bool (UseEvenOdd)"), "{p}"); + + let mut doc = Document::new(300.0, 100.0); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(Paint::None, solid(Color::BLACK), 2.0) }; + let n = named(ring(&mut doc, ap), "Stroked"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(matches!(back.layers[0].children().unwrap()[0].kind, NodeKind::Compound { rule: FillRule::EvenOdd, .. })); +} + +/// A compound path filled by the non-zero rule keeps its effects and its gradient fill. +#[test] +fn a_compound_path_keeps_its_style() { + let mut doc = Document::new(300.0, 100.0); + let non_zero = |mut n: Node| { + if let NodeKind::Compound { rule, .. } = &mut n.kind { + *rule = FillRule::NonZero; + } + n + }; + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), solid(Color::WHITE), 2.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Shadowed"); + put(&mut doc, n); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(graded, Paint::None, 0.0) }; + let n = named(non_zero(ring(&mut doc, ap)), "Graded"); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + assert!(back.layers[0].children().unwrap().iter().all(|n| matches!(n.kind, NodeKind::Compound { .. }))); +} + +/// A group holding an image is written as its drawn look: the image stays a placed file, once. +#[test] +fn a_group_holding_an_image_is_written_as_its_drawn_look() { + let mut doc = Document::new(200.0, 100.0); + let link = Some(vectorcraft_doc::LinkInfo::new("/tmp/ramp.png")); + let im = image_node(&mut doc, ImageBlob::png(rgb_png()), (3, 2), Affine::scale(10.0), link); + let mut g = group(&mut doc, vec![im], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!out.warnings.iter().any(|w| w == writer::LINKS_EMBEDDED), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1)")); + let back = read_back(&doc); + let g = &back.layers[0].children().unwrap()[0]; + let member = g.children().map_or(g, |c| &c[0]); + let NodeKind::Image(im) = &member.kind else { panic!("{member:?}") }; + assert!(im.link.is_some()); +} + +/// Graphic styles and links to them are left out with a warning: an object linked to a style +/// keeps its effects in a style of its own. A new document's styles without a link don't warn. +#[test] +fn graphic_styles_and_links_are_left_out_with_a_warning() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::GRAPHIC_STYLES); + assert!(!warns(&Document::new(200.0, 100.0))); + // A style of the document's own. + let mut own = Document::new(200.0, 100.0); + own.graphic_styles.push(vectorcraft_doc::GraphicStyle::new("Mine", Appearance::basic(black_fill(), Paint::None, 0.0))); + assert!(warns(&own)); + assert!(!shown(&export_ai_editing(&own, &opts()).unwrap().program).contains("ArtStyle")); + assert_eq!(read_back(&own).graphic_style("Mine"), None); + // A link to a new document's style, on an object with an effect. + let mut linked = Document::new(200.0, 100.0); + let id = linked.graphic_style("Sunshine").unwrap().id; + let mut n = plain_rect(&mut linked, "Linked", vec![drop_shadow()]); + n.extras_mut().graphic_style = Some(id); + put(&mut linked, n); + assert!(warns(&linked)); + let back = read_back(&linked); + let n = &back.layers[0].children().unwrap()[0]; + assert_eq!(n.extras().graphic_style, None); + assert_eq!(n.appearance.effects.len(), 1); + // A link in a symbol's art. + let mut in_symbol = Document::new(200.0, 100.0); + let id = in_symbol.graphic_style("Sunshine").unwrap().id; + let mut art = square(&mut in_symbol, Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK); + art.extras_mut().graphic_style = Some(id); + in_symbol.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(art) }); + assert!(warns(&in_symbol)); +} + +/// A path or compound path with no points writes nothing, with or without a style; a group with no +/// members keeps its effects. +#[test] +fn objects_with_nothing_to_write_are_left_out() { + let mut doc = Document::new(200.0, 100.0); + let mut empty = plain_rect(&mut doc, "Empty", vec![drop_shadow()]); + if let NodeKind::Path { path, .. } = &mut empty.kind { + path.subpaths.clear(); + } + put(&mut doc, empty); + let mut hollow = Node::new(doc.alloc_id(), NodeKind::Compound { children: vec![], rule: FillRule::NonZero }); + hollow.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(black_fill(), Paint::None, 0.0) }.into(); + put(&mut doc, named(hollow, "Hollow")); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1)") && !p.contains("(Empty)") && !p.contains("6 () XW"), "{p}"); + assert_eq!(read_back(&doc).layers[0].children().unwrap().len(), 0); + + let (n, ..) = read_one(|doc| { + let mut g = group(doc, vec![], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + named(g, "Bare") + }); + assert_eq!((n.name.as_deref(), n.appearance.effects.len(), n.children().map(Vec::len)), (Some("Bare"), 1, Some(0))); +} + +/// Outline Object, every Convert to Shape form and size, Offset Path's joins, Inner Glow's sources +/// and a stroke's width profile, each on an object, read back as they are. Drop Shadow and the glows +/// keep their colors, in CMYK too. +#[test] +fn every_effect_setting_written_on_an_object_is_read_back() { + use serde_json::json; + let cases = [ + ("path.outlineObject", json!({})), + ("path.outlineStroke", json!({})), + ("convertToShape.rectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})), + ("convertToShape.rectangle", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("convertToShape.roundedRectangle", json!({"relative": false, "width": 30.0, "height": 20.0, "radius": 3.0})), + ("convertToShape.roundedRectangle", json!({"relative": true, "extraW": 6.0, "extraH": 4.0, "radius": 3.0})), + ("convertToShape.ellipse", json!({"relative": false, "width": 30.0, "height": 20.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "round", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": -3.0, "joins": "bevel", "miterLimit": 4.0})), + ("path.offsetPath", json!({"offset": 3.0, "joins": "miter", "miterLimit": 9.0})), + ("stylize.innerGlow", json!({"source": "edge", "blur": 2.5, "opacity": 40.0, "mode": "screen", "color": "#102030"})), + ("stylize.innerGlow", json!({"source": "center", "blur": 2.5})), + ("stylize.outerGlow", json!({"blur": 7.5, "opacity": 33.333, "mode": "normal", "color": "#a0b0c0"})), + ("stylize.dropShadow", json!({"x": 1.25, "y": -2.5, "blur": 3.5, "opacity": 12.5, "mode": "overlay", "color": "#405060"})), + ("stylize.dropShadow", json!({"shade": "darkness", "darkness": 62.5, "x": 0.0, "y": 0.0, "blur": 0.0})), + ("stylize.feather", json!({"radius": 11.5})), + ("stylize.roundCorners", json!({"radius": 0.5})), + ( + "stylize.scribble", + json!({"angle": 15.0, "overlap": 2.0, "overlapVariation": 1.0, "strokeWidth": 1.5, "curviness": 12.5, "curvinessVariation": 7.5, "spacing": 4.0, "spacingVariation": 1.5}), + ), + ]; + for (id, params) in cases { + let effect = fx(id, params.clone()); + let (n, warnings, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{id} {params}: {warnings:?}\n{p}"); + assert_eq!(n.appearance.effects.len(), 1, "{id} {params}"); + let (got, want) = (&n.appearance.effects[0], &effect); + assert_eq!(got.id, want.id); + let (g, w) = (vectorcraft_effects::merged_params(&got.id, &got.params), vectorcraft_effects::merged_params(&want.id, &want.params)); + for (key, value) in w.as_object().unwrap() { + let read = g.get(key).unwrap_or(&serde_json::Value::Null); + match (key.as_str(), value.as_f64()) { + // A case the file doesn't keep: `seed` and the like, which the reader fills in as their defaults. + ("mode" | "shade" | "source" | "joins", _) => { + let lower = |v: &serde_json::Value| v.as_str().unwrap_or_default().to_ascii_lowercase(); + assert_eq!(lower(read), lower(value), "{id} {key}"); + } + ("color", _) => { + let color = |p: &serde_json::Value| vectorcraft_effects::color_param(p, Color::BLACK).to_hex(); + assert_eq!(color(&g), color(&w), "{id}"); + } + (_, Some(v)) => assert!((read.as_f64().unwrap() - v).abs() < 6e-3, "{id} {key}: {read} for {v}"), + _ => assert_eq!(read, value, "{id} {key}"), + } + } + } + // A relative Ellipse: the reader keeps the drawn look, which is the effect's result. + let effect = fx("convertToShape.ellipse", json!({"relative": true, "extraW": 6.0, "extraH": 4.0})); + let (n, _, p) = read_one(|doc| plain_rect(doc, "One", vec![effect.clone()])); + assert!(!p.contains("%AI9_BeginArtStyles"), "{p}"); + assert!(n.appearance.effects.is_empty()); + // A stroke's width profile. + let profile = vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }; + let (n, warnings, p) = read_one(|doc| { + let mut stroke = StrokeLayer::new(black_fill(), 4.0); + stroke.profile = Some(profile.clone()); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], effects: vec![drop_shadow()], contents_index: None }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }); + assert!(!warnings.iter().any(|w| w == writer::EFFECTS), "{warnings:?}"); + assert!(p.contains("(BSVariableWidthTValuesArray)") && p.contains("(BSLeftWidthsArray)") && p.contains("(BSRightWidthsArray)"), "{p}"); + assert_eq!(n.appearance.stroke().and_then(|s| s.profile.clone()), Some(profile)); + // A CMYK document: a CMYK and a spot fill, a CMYK stroke, and an effect's CMYK color stay as they are. + let mut doc = Document::new(200.0, 100.0); + doc.color_mode = vectorcraft_doc::ColorMode::Cmyk; + let mut swatches = (*doc.swatches).clone(); + swatches.push(Swatch { name: "Ink".into(), paint: solid(INK), global: true, spot: true }); + swatches.push(Swatch { name: "Brand".into(), paint: solid(BRAND), global: true, spot: false }); + doc.swatches = swatches.into(); + let shadow = fx("stylize.dropShadow", json!({"color": serde_json::to_value(GOLD).unwrap()})); + let n = styled_rect(&mut doc, "Process", Rect::new(10.0, 10.0, 40.0, 40.0), solid(GOLD), solid(BRAND), vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Spot", Rect::new(50.0, 10.0, 80.0, 40.0), linked("Ink", INK, 0.5), Paint::None, vec![shadow.clone()]); + put(&mut doc, n); + let n = styled_rect(&mut doc, "Global", Rect::new(90.0, 10.0, 120.0, 40.0), linked("Brand", BRAND, 1.0), Paint::None, vec![shadow]); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + for n in back.layers[0].children().unwrap() { + assert!(matches!(vectorcraft_effects::color_param(&n.appearance.effects[0].params, Color::BLACK), Color::Cmyk { .. }), "{n:?}"); + } + let spot = &back.layers[0].children().unwrap()[1]; + assert!(matches!(&spot.appearance.fill().unwrap().paint, Paint::Solid { swatch: Some(s), .. } if s == "Ink"), "{spot:?}"); +} + +/// Every kind of appearance a style isn't written for is written as its drawn look, with a warning. +#[test] +fn appearances_written_as_their_drawn_look() { + type Make = Box Node>; + let cases: Vec<(&str, Make)> = vec![ + ( + "a pattern fill", + Box::new(|doc| { + let tile = square(doc, Rect::new(0.0, 0.0, 5.0, 5.0), Color::BLACK); + let mut dots = vectorcraft_doc::PatternDef::new("Dots", vec![Arc::new(tile)]); + dots.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(dots); + let fill = Paint::Pattern { pattern: "Dots".into(), xf: Affine::IDENTITY }; + styled_rect(doc, "P", Rect::new(10.0, 10.0, 50.0, 40.0), fill, Paint::None, vec![drop_shadow()]) + }), + ), + ( + "an overprinting fill", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { overprint: true, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "an overprinting stroke", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Stroke(StrokeLayer { overprint: true, ..StrokeLayer::new(black_fill(), 2.0) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a gradient stroke", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + styled_rect(doc, "G", Rect::new(10.0, 10.0, 50.0, 40.0), Paint::None, graded, vec![drop_shadow()]) + }), + ), + ( + "a fill with an opacity of its own", + Box::new(|doc| { + let ap = Appearance { + items: vec![AppearanceItem::Fill(FillLayer { opacity: 0.5, ..FillLayer::new(black_fill()) })], + effects: vec![drop_shadow()], + contents_index: None, + }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ( + "a fill with an effect of its own", + Box::new(|doc| { + let fill = FillLayer { effects: vec![fx("stylize.feather", serde_json::json!({}))], ..FillLayer::new(black_fill()) }; + let ap = Appearance { items: vec![AppearanceItem::Fill(fill)], ..Appearance::default() }; + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap) + }), + ), + ("Pathfinder on a path", Box::new(|doc| plain_rect(doc, "P", vec![fx("pathfinder.add", serde_json::json!({}))]))), + ( + "a group with a fill of its own", + Box::new(|doc| { + let member = square(doc, Rect::new(10.0, 10.0, 40.0, 40.0), Color::BLACK); + let mut g = group(doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::basic(solid(Color::WHITE), Paint::None, 0.0) }.into(); + g + }), + ), + ("an effect the style doesn't hold", Box::new(|doc| plain_rect(doc, "B", vec![fx("blur.gaussian", serde_json::json!({}))]))), + ( + "a group whose member's stroke is written as its outline", + Box::new(|doc| { + let mut stroke = StrokeLayer::new(solid(Color::BLACK), 4.0); + stroke.profile = Some(vectorcraft_doc::WidthProfile { points: vec![(0.0, 1.0, 0.5), (0.5, 0.25, 0.75), (1.0, 0.0, 1.0)] }); + let ap = Appearance { items: vec![AppearanceItem::Stroke(stroke)], ..Appearance::default() }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 40.0, 40.0)), ap); + let mut g = group(doc, vec![member], false); + let effects = vec![fx("stylize.roundCorners", serde_json::json!({})), drop_shadow()]; + g.appearance = Appearance { effects, ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member filled with pattern tiles", + Box::new(|doc| { + let big = square(doc, Rect::new(-2.0, -2.0, 6.0, 6.0), Color::rgb(1.0, 0.0, 0.0)); + let mut over = vectorcraft_doc::PatternDef::new("Over", vec![Arc::new(big)]); + over.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(over); + let tiles = Paint::Pattern { pattern: "Over".into(), xf: Affine::IDENTITY }; + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 3.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "a group whose effects reshape a member with a gradient that fits it", + Box::new(|doc| { + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let member = + Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(30.0, 10.0, 100.0, 40.0)), Appearance::basic(graded, Paint::None, 0.0)); + let mut g = group(doc, vec![member], false); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 15.0})); + g.appearance = Appearance { effects: vec![offset, drop_shadow()], ..Appearance::default() }.into(); + g + }), + ), + ( + "Define Knockout Shape", + Box::new(|doc| { + let mut n = plain_rect(doc, "K", vec![drop_shadow()]); + n.knockout_shape = true; + n + }), + ), + ]; + for (what, make) in cases { + let mut doc = Document::new(200.0, 100.0); + let n = make(&mut doc); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{what}: {:?}", out.warnings); + let p = shown(&out.program); + assert!(!p.contains("%AI9_BeginArtStyles") && !p.contains("(Anon 1) XW"), "{what}:\n{p}"); + let back = read_back(&doc); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty(), "{what}: {n:?}"); + } +} + +/// A gradient fill that fits its object is placed on the outline its geometry effects draw: an +/// object with Offset Path reads back with the gradient a plain object of the drawn size has. +#[test] +fn a_styled_objects_gradient_fits_its_drawn_outline() { + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = || Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops())))); + let first = |doc: &Document| doc.layers[0].children().unwrap()[0].clone(); + let mut doc = Document::new(200.0, 100.0); + let offset = fx("path.offsetPath", serde_json::json!({"offset": 20.0, "joins": "miter"})); + let n = styled_rect(&mut doc, "G", Rect::new(50.0, 30.0, 100.0, 60.0), graded(), Paint::None, vec![offset]); + put(&mut doc, n); + let styled = first(&read_back(&doc)); + assert_eq!(styled.appearance.effects.len(), 1); + let mut doc = Document::new(200.0, 100.0); + let n = styled_rect(&mut doc, "P", Rect::new(30.0, 10.0, 120.0, 80.0), graded(), Paint::None, vec![]); + put(&mut doc, n); + let plain = first(&read_back(&doc)); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!("{:.2} {:.2} {:.2} {:.2} {:.2}", g.start.x, g.start.y, g.end.x, g.end.y, g.aspect) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(&styled), geom(&plain)); +} + +/// The effects of a pattern's tile art are its own: a styled group's drawn look that first uses the +/// pattern still warns that the tile's Gaussian Blur is left out. +#[test] +fn effects_in_a_pattern_a_styled_look_uses_are_warned_about() { + let mut doc = Document::new(200.0, 100.0); + let dot = styled_rect( + &mut doc, + "Dot", + Rect::new(2.0, 2.0, 8.0, 8.0), + solid(Color::BLACK), + Paint::None, + vec![fx("blur.gaussian", serde_json::json!({}))], + ); + let mut grid = vectorcraft_doc::PatternDef::new("Grid", vec![Arc::new(dot)]); + grid.tile = Rect::new(0.0, 0.0, 10.0, 10.0); + doc.patterns.push(grid); + let tiles = Paint::Pattern { pattern: "Grid".into(), xf: Affine::IDENTITY }; + let member = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::basic(tiles, Paint::None, 0.0)); + let mut g = group(&mut doc, vec![member], false); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(p.contains("%AI3_BeginPattern") && p.contains("\n1 (Anon 1) XW\n"), "{p}"); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); +} + +/// A style that isn't written defines no gradient: a hidden gradient fill that only the style +/// would have held leaves no gradient the art uses. +#[test] +fn a_style_that_isnt_written_defines_no_gradient() { + let mut doc = Document::new(200.0, 100.0); + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let graded = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + let ap = Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer { visible: false, ..FillLayer::new(graded) }), + AppearanceItem::Stroke(StrokeLayer::new(solid(Color::BLACK), 2.0)), + ], + effects: vec![fx("blur.gaussian", serde_json::json!({}))], + contents_index: None, + }; + let n = Node::path(doc.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 50.0, 40.0)), ap); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + let p = shown(&out.program); + assert!(!p.contains("(Anon 1) XW"), "{p}"); + assert!(p.contains("%%BeginSetup\n0 Bn\n"), "{p}"); +} + +/// An object in a plain group, a sublayer or a clipping group by a path keeps its style there. One +/// in a symbol's art, in an opacity mask or in art cut to a clipping object other than a path is +/// written as its drawn look, with a warning. +#[test] +fn where_a_styled_object_keeps_its_style() { + let inside = |doc: &mut Document| plain_rect(doc, "Inside", vec![drop_shadow()]); + let found = |doc: &Document| { + fn find(nodes: &[Arc]) -> Option<&Arc> { + nodes.iter().find_map(|n| if n.name.as_deref() == Some("Inside") { Some(n) } else { n.children().and_then(|c| find(c)) }) + } + find(&doc.layers).cloned() + }; + // In a plain group. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let g = group(&mut doc, vec![n], false); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + same_styles(&read_back(&doc), &doc); + // In a sublayer. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + let mut sub = Node::layer(doc.alloc_id(), "Sub", LayerColor::Preset(1)); + sub.children_mut().unwrap().push(Arc::new(n)); + put(&mut doc, sub); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a clipping group. + let mut doc = Document::new(200.0, 100.0); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(0.0, 0.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let n = inside(&mut doc); + let g = group(&mut doc, vec![clip, n], true); + put(&mut doc, g); + assert_eq!(found(&read_back(&doc)).unwrap().appearance.effects.len(), 1); + // In a symbol's art: the instance keeps the symbol, the effects are left out. + let mut doc = Document::new(200.0, 100.0); + let n = inside(&mut doc); + doc.symbols.push(Symbol { dynamic: false, name: "S".into(), art: Arc::new(n) }); + let instance = Node::new(doc.alloc_id(), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::translate((50.0, 20.0)) }); + put(&mut doc, instance); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In an opacity mask. + let mut doc = Document::new(200.0, 100.0); + let art = inside(&mut doc); + let mut n = square(&mut doc, Rect::new(10.0, 10.0, 60.0, 60.0), Color::BLACK); + n.extras_mut().mask = Some(OpacityMask { art: Arc::new(art), clip: true, invert: false, disabled: false, linked: true }.into()); + put(&mut doc, n); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); + // In art cut to a clipping object other than a path: type. + let mut doc = Document::new(200.0, 100.0); + let t = vectorcraft_doc::TextObject::point( + vectorcraft_geom::Point::new(10.0, 70.0), + "Type", + vectorcraft_doc::CharStyle { size: 18.0, fill: solid(Color::BLACK), ..vectorcraft_doc::CharStyle::default() }, + ); + let t = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let n = inside(&mut doc); + let g = group(&mut doc, vec![t, n], true); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + assert!(!shown(&out.program).contains("(Anon 1) XW")); +} + +/// A styled group's members of every kind allowed come back as they were: a compound path, a named +/// path, a hidden and a locked path, a nested group and a clipping group. +#[test] +fn a_styled_groups_members_of_each_kind_are_read_back() { + let mut doc = Document::new(200.0, 100.0); + let solid_ap = Appearance::basic(solid(Color::rgb(0.2, 0.4, 0.6)), Paint::None, 0.0); + let compound = named(ring(&mut doc, solid_ap.clone()), "Compound"); + let plain = named(square(&mut doc, Rect::new(10.0, 10.0, 30.0, 30.0), Color::rgb(0.8, 0.1, 0.1)), "Named"); + let mut quiet = named(square(&mut doc, Rect::new(30.0, 10.0, 50.0, 30.0), Color::rgb(0.1, 0.8, 0.1)), "Hidden"); + quiet.visible = false; + let mut fixed = named(square(&mut doc, Rect::new(50.0, 10.0, 70.0, 30.0), Color::rgb(0.1, 0.1, 0.8)), "Locked"); + fixed.locked = true; + let a = square(&mut doc, Rect::new(10.0, 40.0, 30.0, 60.0), Color::BLACK); + let nested = named(group(&mut doc, vec![a], false), "Nested"); + let clip = Node::new( + doc.alloc_id(), + NodeKind::Path { + path: shapes::rectangle(Rect::new(40.0, 40.0, 60.0, 60.0)), + rule: FillRule::NonZero, + live: None, + clipping: true, + guide: false, + }, + ); + let b = square(&mut doc, Rect::new(40.0, 40.0, 80.0, 80.0), Color::WHITE); + let cut = named(group(&mut doc, vec![clip, b], true), "Cut"); + let mut g = named(group(&mut doc, vec![compound, plain, quiet, fixed, nested, cut], false), "Holder"); + g.appearance = Appearance { effects: vec![drop_shadow()], ..Appearance::default() }.into(); + put(&mut doc, g); + let out = export_ai_editing(&doc, &opts()).unwrap(); + assert!(!out.warnings.iter().any(|w| w == writer::EFFECTS), "{:?}", out.warnings); + let back = read_back(&doc); + same_styles(&back, &doc); + let members = |d: &Document| { + let mut out = vec![]; + view(d.layers[0].children().unwrap()[0].children().unwrap(), 0, &mut out); + out + }; + same_lines(&members(&back), &members(&doc)); +} + +/// What compares two appearances for the writer's checks and for these tests tells them apart. +#[test] +fn same_appearance_tells_appearances_apart() { + use serde_json::json; + let base = || Appearance { + items: vec![ + AppearanceItem::Fill(FillLayer::new(solid(Color::rgb(0.5, 0.25, 0.75)))), + AppearanceItem::Stroke(StrokeLayer::new(black_fill(), 2.0)), + ], + effects: vec![fx("stylize.dropShadow", json!({"x": 10.0, "mode": "Multiply", "color": "#102030"}))], + contents_index: None, + }; + let same = writer::same_appearance; + assert!(same(&base(), &base())); + // Within the written precision, a mode in another case and a color in another form. + let mut other = base(); + other.effects[0].params["x"] = json!(10.005); + other.effects[0].params["mode"] = json!("multiply"); + other.effects[0].params["color"] = serde_json::to_value(Color::from_hex("#102030").unwrap()).unwrap(); + assert!(same(&other, &base())); + // A fill or stroke that doesn't paint. + let mut other = base(); + other.items.push(AppearanceItem::Fill(FillLayer::new(Paint::None))); + assert!(same(&other, &base())); + type Change = Box; + let change: Vec = vec![ + Box::new(|a| a.effects[0].params["x"] = json!(10.01)), + Box::new(|a| a.effects[0].params["color"] = json!("#102031")), + Box::new(|a| a.effects[0].params["mode"] = json!("screen")), + Box::new(|a| a.effects[0].visible = false), + Box::new(|a| a.effects[0].id = "stylize.outerGlow".into()), + Box::new(|a| a.effects.clear()), + Box::new(|a| a.items.truncate(1)), + Box::new(|a| a.items.swap(0, 1)), + Box::new(|a| a.contents_index = Some(2)), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.paint = solid(Color::rgb(0.5, 0.25, 0.76)); + } + }), + Box::new(|a| { + if let AppearanceItem::Fill(f) = &mut a.items[0] { + f.opacity = 0.5; + } + }), + Box::new(|a| { + if let AppearanceItem::Stroke(s) = &mut a.items[1] { + s.width = 2.5; + } + }), + ]; + for (i, f) in change.iter().enumerate() { + let mut other = base(); + f(&mut other); + assert!(!same(&other, &base()), "change {i}"); + } +} + +/// A gradient fill in a style is placed as on an object without a style: a turned linear gradient +/// and a radial one with a focal point, and on a first artboard away from the origin among several. +#[test] +fn a_styled_objects_gradient_is_placed_as_a_plain_objects_is() { + use vectorcraft_color::gradient::GradientGeom; + let stops = || vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::rgb(0.1, 0.2, 0.8))]; + let turned = GradientGeom { start: (120.0, 60.0).into(), end: (170.0, 90.0).into(), aspect: 1.0, focal: None }; + let mut focal = GradientGeom { start: (140.0, 70.0).into(), end: (170.0, 70.0).into(), aspect: 0.5, focal: None }; + focal.set_focal(Some((150.0, 65.0).into())); + let boards = [Rect::new(0.0, 0.0, 300.0, 200.0), Rect::new(100.0, 50.0, 400.0, 250.0)]; + for (kind, geom) in [(GradientKind::Linear, turned), (GradientKind::Radial, focal)] { + for board in boards { + let paint = gradient_paint(Gradient::new(kind, stops()), Some(geom), None); + let make = |effects: Vec| { + let mut doc = Document::new(300.0, 200.0); + doc.artboards[0].rect = board; + let second = doc.artboards[0].clone(); + doc.artboards.push(Artboard { rect: board + vectorcraft_geom::Vec2::new(400.0, 0.0), ..second }); + let n = styled_rect(&mut doc, "G", Rect::new(120.0, 60.0, 180.0, 100.0), paint.clone(), Paint::None, effects); + put(&mut doc, n); + doc + }; + let (with, without) = (read_back(&make(vec![drop_shadow()])), read_back(&make(vec![]))); + let (a, b) = (&with.layers[0].children().unwrap()[0], &without.layers[0].children().unwrap()[0]); + assert_eq!(a.appearance.effects.len(), 1); + let geom = |n: &Node| match &n.appearance.fill().unwrap().paint { + Paint::Gradient(g) => { + let g = g.geom.unwrap(); + format!( + "{:.2} {:.2} {:.2} {:.2} {:.2} {:?}", + g.start.x, + g.start.y, + g.end.x, + g.end.y, + g.aspect, + g.focal.map(|f| (r3(f.x), r3(f.y))) + ) + } + other => panic!("{other:?}"), + }; + assert_eq!(geom(a), geom(b)); + same_lines(&outlines(&with), &outlines(&without)); + } + } +} + +/// On a large canvas an object with an effect warns and comes back as its drawn look at its place. +#[test] +fn a_large_canvas_writes_every_object_as_its_look() { + let warns = |doc: &Document| export_ai_editing(doc, &opts()).unwrap().warnings.iter().any(|w| w == writer::LARGE_CANVAS_STYLES); + let mut plain = Document::new(20000.0, 100.0); + let n = plain_rect(&mut plain, "None", vec![]); + put(&mut plain, n); + assert!(!warns(&plain)); + let mut shaded = Document::new(20000.0, 100.0); + let n = plain_rect(&mut shaded, "Shaded", vec![drop_shadow()]); + put(&mut shaded, n); + assert!(warns(&shaded)); + let back = read_back(&shaded); + let n = &back.layers[0].children().unwrap()[0]; + assert!(n.appearance.effects.is_empty()); + let p = n.path_data().unwrap().subpaths[0].anchors[0].p; + assert!((p.x - 10.0).abs() < 1e-2 && (p.y - 10.0).abs() < 1e-2, "{p:?}"); +} + // ---------- variables ---------- /// A 200 × 100 document with variables: a text variable `name` bound to point type, a visibility diff --git a/crates/eps/src/ai_editing/writer.rs b/crates/eps/src/ai_editing/writer.rs index 2ac1edd63..5bb33aa52 100644 --- a/crates/eps/src/ai_editing/writer.rs +++ b/crates/eps/src/ai_editing/writer.rs @@ -17,15 +17,23 @@ use crate::ps; mod images; mod paints; +mod styles; +mod symbols; mod texts; mod variables; +#[cfg(test)] +pub(super) use styles::same_appearance; +pub(super) use styles::{GRAPHIC_STYLES, LARGE_CANVAS_STYLES, has_graphic_styles, styled_objects}; + pub(super) use images::PAYLOAD; #[cfg(test)] pub(super) use images::{IMAGE_DEPTH, IMAGES, LINKS_EMBEDDED}; #[cfg(test)] pub(super) use paints::{GRADIENT_MIX, GRADIENT_SHAPES, GRADIENTS, STOP_LINKS, SWATCH_PLACEMENT, free_gradient_name}; #[cfg(test)] +pub(super) use symbols::{MISSING_SYMBOL, SYMBOL_ART, SYMBOL_COUNT, SYMBOL_OPTIONS}; +#[cfg(test)] pub(super) use texts::TYPE_ATTRIBUTES; #[cfg(test)] pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES}; @@ -35,14 +43,14 @@ pub(super) use variables::{ACTIVE, BINDINGS, LIMITS, VALUES}; /// [`vectorcraft_doc::MAX_DEPTH`]. const MAX_NEST: u32 = 8; -pub(super) const TYPE: &str = "type in opacity masks, pattern tiles and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; -pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; +pub(super) const TYPE: &str = "type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, area type and type on a path that is turned or unevenly scaled or whose frame or path has several parts, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation or hold a frame written as outlines, type of sizes past what the reader reads, stories past what the reader reads (1,048,576 bytes a story, 16,777,216 bytes in all, and the size of text document it reads), and type with inline graphics, ruby, gradient or pattern paint, or fills, strokes, effects or an opacity mask of the object's own, is written as its outlines in the editing data for other apps"; +pub(super) const PATTERNS: &str = "pattern fills whose tiles aren't a grid or whose art reaches past the tile, pattern fills in another pattern's tile or in a symbol, and pattern fills nested more than 8 deep are written as their tiles cut to the shape in the editing data for other apps"; pub(super) const PATTERN_TILES: &str = "pattern fills with more tiles than a fill places are written filled gray in the editing data for other apps"; pub(super) const CLIPPING: &str = "clipping groups and clipping layers whose clipping object isn't a path, and the clipping groups inside them, are written as their art cut to that object in the editing data for other apps"; pub(super) const CLIP_PAINT: &str = "the fills and strokes of clipping paths that a plain fill and line can't draw are written as paths of their own inside the clipping (fills under the clipped art, strokes over it) in the editing data for other apps"; pub(super) const CLIP_PAINT_MASKS: &str = "the opacity masks of clipping paths whose fills and strokes are written as paths of their own stay on the clipping path, not on those paths, in the editing data for other apps"; pub(super) const LIVE: &str = - "symbols, blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; + "blends, envelopes, meshes, repeats, placed documents and intertwines are written as their art in the editing data for other apps"; const STROKES: &str = "strokes a plain line can't draw (inside or outside the path, variable width, dots, dashes fitted to corners, gradients, patterns, arrowheads) are written as filled outlines, and brushed strokes as their brush's art, in the editing data for other apps"; pub(super) const ITEM_TRANSPARENCY: &str = "the opacity and blending modes of single fills and strokes are left out of the editing data for other apps"; @@ -172,6 +180,57 @@ pub(super) struct Writer<'a> { bound: Vec<(vectorcraft_doc::NodeId, String, Vec)>, /// The text document: the stories of the type written as text. pub(super) texts: texts::TextDoc, + /// Symbol instances on the layers may be written as instances ([`Self::live_instance`]); false + /// on a large canvas, where they are written as their art. + pub(super) symbols_live: bool, + /// The document symbols are defined from: on a large canvas, the document at its own size + /// (symbols' art is in its own space, which the canvas's scale doesn't change). + pub(super) symbol_doc: &'a Document, + /// A symbol's art is being written ([`Self::define_symbol`]). + in_symbol: bool, + /// The document → the space an instance's `RTransformMatrix` takes its symbol's art to: art + /// space, and in a symbol's art the canvas's turned y up about art space's origin there. + instance_space: Affine, + /// The symbol definitions written (`%AI14_BeginSymbol` … `%AI10_EndSymbol`), each after those + /// its art uses, and their names. + symbol_defs: Vec, + defined_symbols: HashSet, + /// The symbols whose definitions are being written. + defining_symbols: Vec, + /// Whether each symbol's own art paints with a pattern, and the symbols its instances use + /// ([`Self::symbol_own_patterns`]). + pattern_symbols: HashMap)>, + /// Whether each symbol's art paints with a pattern, nested symbols included + /// ([`Self::symbol_paints_pattern`]). + patterned_symbols: HashMap, + /// Pattern tiles were written in the art being written ([`Self::define_symbol`] starts it over + /// for each symbol's art). + wrote_pattern: bool, + /// The symbols whose definitions hold pattern tiles: a brushed stroke's brush art or a live + /// object's art can paint with a pattern that the symbol's own objects don't name. + pattern_written: HashSet, + /// Art that draws as it should only where it is was written in the art being written + /// ([`Self::define_symbol`] starts it over for each symbol's art): a stroke as its outline or its + /// brush's art, type as its outlines, a live object or a symbol instance as its art, or a shape + /// with rounded live corners. + wrote_fixed: bool, + /// The symbols whose definitions hold such art, nested symbols' included + /// ([`Self::keeps_scale`]). + fixed_symbols: HashSet, + /// The largest scale a live instance in the art being written applies, nested symbols' scales + /// included, and that of each symbol's definition. + inner_scale: f64, + symbol_scales: HashMap, + /// The entries a symbol's dictionary adds to its art's dictionary, and the art's id. + root_entries: Option<(vectorcraft_doc::NodeId, String)>, + /// The objects that may be written with an art style, as they were before baking, by id + /// ([`styles::styled_objects`]). + pub(super) styled: HashMap>, + /// The art styles written ([`styles`]). + styles: styles::Styles, + /// An object's drawn look is being written: no object in it is written with a style, and its + /// effects are the style's. + in_look: bool, } impl<'a> Writer<'a> { @@ -208,6 +267,25 @@ impl<'a> Writer<'a> { next: HashMap::new(), brushes: None, symbol_nodes: 0, + symbols_live: true, + symbol_doc: doc, + in_symbol: false, + instance_space: to_art, + symbol_defs: vec![], + defined_symbols: HashSet::new(), + defining_symbols: vec![], + pattern_symbols: HashMap::new(), + patterned_symbols: HashMap::new(), + wrote_pattern: false, + pattern_written: HashSet::new(), + wrote_fixed: false, + fixed_symbols: HashSet::new(), + inner_scale: 1.0, + symbol_scales: HashMap::new(), + root_entries: None, + styled: HashMap::new(), + styles: styles::Styles::default(), + in_look: false, } } @@ -396,6 +474,9 @@ impl<'a> Writer<'a> { /// Warn when `n` has effects the data leaves out. fn effects(&mut self, n: &Node) { + if self.in_look { + return; + } let items = n.appearance.items.iter().filter(|i| i.visible()).flat_map(|i| i.effects()); if n.appearance.effects.iter().chain(items).any(|e| e.visible) { self.warn(EFFECTS); @@ -407,6 +488,20 @@ impl<'a> Writer<'a> { if let NodeKind::Layer { .. } = n.kind { return self.write_layer(n, &[]); } + if self.in_symbol { + self.wrote_fixed |= symbols::redrawn(n); + } + // On the layers, outside art cut to a clipping object: with its art style, when it has one. + if !self.in_look + && self.hidden == 0 + && self.nest == 0 + && self.clip.is_none() + && !self.in_symbol + && let Some(orig) = self.styled.get(&n.id).cloned() + && let Some(name) = self.object_style(n, &orig) + { + return self.styled_object(n, &orig, &name); + } self.effects(n); self.state(n.locked, !n.visible); let xy = Xy::of(n); @@ -435,10 +530,18 @@ impl<'a> Writer<'a> { NodeKind::Compound { rule, .. } => self.shape(n, &compound_path(n), *rule, xy), NodeKind::Text(_) => self.text(n, xy), NodeKind::Image(im) => self.image(im, xy), + NodeKind::SymbolInstance { symbol, xf } if self.live_instance(n, symbol, *xf) => self.symbol_instance(symbol, *xf, xy), NodeKind::SymbolInstance { symbol, xf } => { - self.warn(LIVE); - self.line("u"); + // The canvas places an instance inside a symbol's art where the outer instance puts + // it (9-slice guides, stains and patterns included), and this art doesn't move with it. + self.wrote_fixed |= self.in_symbol; let doc = self.doc; + if doc.symbols.iter().any(|s| &s.name == symbol) { + self.warn(symbols::SYMBOL_ART); + } else { + self.warn(symbols::MISSING_SYMBOL); + } + self.line("u"); if let Some(sym) = doc.symbols.iter().find(|s| &s.name == symbol) && self.nest < MAX_NEST { @@ -506,7 +609,8 @@ impl<'a> Writer<'a> { /// `n`'s `/ArtDictionary`, after it, when it has a name (`AI10_ArtUID`, see [`Self::take_id`]), /// is bound to variables ([`Self::bound_variables`]; without a name, it gets an XML id and an - /// empty `AIArtName`) or has an opacity mask (`AI9_Mask`, see [`Self::mask`]). + /// empty `AIArtName`), has an opacity mask (`AI9_Mask`, see [`Self::mask`]), or is a symbol's + /// art (its symbol's entries first, see [`Self::define_symbol`]). fn dictionary(&mut self, n: &Node) { let name = n.name.as_deref().filter(|s| !s.is_empty()); let placed = std::mem::take(&mut self.placed); @@ -518,10 +622,17 @@ impl<'a> Writer<'a> { self.warn(DEEP_MASKS); mask = None; } - if name.is_none() && mask.is_none() && bound.is_empty() { + let entries = match &self.root_entries { + Some((id, _)) if *id == n.id => self.root_entries.take().map(|(_, e)| e), + _ => None, + }; + if name.is_none() && mask.is_none() && bound.is_empty() && entries.is_none() { return; } self.body.push_str("%_/ArtDictionary :\n"); + if let Some(e) = entries { + self.body.push_str(&e); + } if name.is_some() || !bound.is_empty() { let id = self.take_id(name.unwrap_or("XMLID")); let _ = writeln!(self.body, "%_/XMLUID : {} ; (AI10_ArtUID) ,", ps::bytes(id.as_bytes())); @@ -775,6 +886,7 @@ impl<'a> Writer<'a> { AppearanceItem::Stroke(s) if self.direct_stroke(s) => self.path_object(path, rule, None, Some(s), xy), AppearanceItem::Stroke(s) => { self.warn(STROKES); + self.wrote_fixed = true; if self.brush_art(path, s, xy) { return; } @@ -836,6 +948,7 @@ impl<'a> Writer<'a> { let flat = FillLayer { paint: Paint::solid(Color::gray(0.5)), ..f.clone() }; return self.path_object(path, rule, Some(&flat), None, xy); } + self.wrote_pattern = true; let outer = self.clip.clone(); let region = self.cut(path, rule); self.clip = Some(region); @@ -874,6 +987,7 @@ impl<'a> Writer<'a> { self.plain_object(®ion, FillRule::NonZero, fill, None, own); } if let Some(s) = stroke { + self.wrote_fixed = true; let outline = stroke::outline_region(path, rule, s); let region = self.cut(&outline, FillRule::NonZero); let fill = FillLayer { overprint: s.overprint, ..FillLayer::new(s.paint.clone()) }; @@ -937,13 +1051,13 @@ impl<'a> Writer<'a> { // ---------- type ---------- /// Type `n`: a text object stub naming its story in the text document ([`texts`]) when the - /// text document can hold it, outside opacity masks, pattern tiles and art cut to a clipping - /// object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type names the - /// story of its thread, which its first frame holds. + /// text document can hold it, outside opacity masks, pattern tiles, symbols' art and art cut to + /// a clipping object; else its outlines ([`Self::outlined_text`]). A frame of threaded area type + /// names the story of its thread, which its first frame holds. fn text(&mut self, n: &Node, xy: Xy) { let NodeKind::Text(t) = &n.kind else { return }; let doc = self.doc; - let placed = if self.hidden > 0 || self.clip.is_some() { + let placed = if self.hidden > 0 || self.clip.is_some() || self.in_symbol { None } else if let Some(thread) = doc.text_threads.iter().find(|th| th.contains(&n.id)) { // A thread is checked for what it loses, in every frame, when its story is written. diff --git a/crates/eps/src/ai_editing/writer/images.rs b/crates/eps/src/ai_editing/writer/images.rs index 0248e8b9a..898c91aa7 100644 --- a/crates/eps/src/ai_editing/writer/images.rs +++ b/crates/eps/src/ai_editing/writer/images.rs @@ -13,7 +13,7 @@ use crate::ps; pub(in crate::ai_editing) const IMAGES: &str = "images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray in the editing data for other apps"; pub(in crate::ai_editing) const IMAGE_DEPTH: &str = "images of 16 bits a channel are written at 8 bits a channel in the editing data for other apps"; pub(in crate::ai_editing) const LINKS_EMBEDDED: &str = - "linked images in opacity masks and pattern tiles are written as embedded images in the editing data for other apps"; + "linked images in opacity masks, pattern tiles and symbols are written as embedded images in the editing data for other apps"; /// The most pixels an image is written with: as many as the reader reads. pub(super) const MAX_PIXELS: u64 = 1 << 25; @@ -94,7 +94,7 @@ impl Samples { impl Writer<'_> { /// Image object `im` of transparency `xy`: embedded, a raster ([`Self::raster`]); linked, a /// placed file with a preview ([`Self::placed_file`]), except in a part of the data written on - /// `%_` lines. One whose pixels can't be read is its outline filled mid gray. Where the art is + /// `%_` lines and in a symbol's art. One whose pixels can't be read is its outline filled mid gray. Where the art is /// cut to a clipping path, the image is in a clipping group of that path. pub(super) fn image(&mut self, im: &ImageObject, xy: Xy) { let doc = self.doc; @@ -120,7 +120,7 @@ impl Writer<'_> { let fit = Affine::scale_non_uniform(f64::from(im.width.max(1)) / f64::from(samples.width), f64::from(im.height.max(1)) / f64::from(samples.height)); match &im.link { - Some(link) if self.hidden == 0 => { + Some(link) if self.hidden == 0 && !self.in_symbol => { // Cut to a region, the placed file is in a clipping group. self.placed = cut.is_none(); self.placed_file(link, &samples, (im.width.max(1), im.height.max(1)), im.xf); @@ -158,9 +158,12 @@ impl Writer<'_> { /// `[matrix] 0 0 w h w h 8 type alpha reserved 1 0 4 4 0 0`, `%%BeginData:` counting `XI`, its /// line end and the color samples, `XI`, the color samples then the alpha channel in binary, /// `%%EndData`, `XH`, `%AI5_EndRaster`, `N`. The matrix is an art-space matrix applied to the - /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix. + /// pixels with their rows running downwards, `(u, -v)`, as §8.1's image matrix; in a symbol's + /// art (outside a pattern's tile in it), a matrix in the canvas's space turned y up about art + /// space's origin there, as an instance's matrix in it is. fn raster(&mut self, s: &Samples, xf: Affine) { - let m = matrix(self.to_art * xf * Affine::FLIP_Y); + let space = if self.in_symbol && self.defining.is_empty() { self.instance_space } else { self.to_art }; + let m = matrix(space * xf * Affine::FLIP_Y); let (w, h) = (s.width, s.height); let alpha = u8::from(s.alpha.is_some()); let mut payload = Vec::with_capacity(2 + s.color.len() + s.alpha.as_ref().map_or(0, Vec::len)); @@ -237,7 +240,7 @@ fn matrix(m: Affine) -> String { } /// `v` rounded to ten decimals, without trailing zeros (0 when not finite). -fn precise(v: f64) -> String { +pub(super) fn precise(v: f64) -> String { let r = (v * 1e10).round() / 1e10; if !r.is_finite() { return if v.is_finite() { format!("{}", v + 0.0) } else { "0".into() }; diff --git a/crates/eps/src/ai_editing/writer/paints.rs b/crates/eps/src/ai_editing/writer/paints.rs index d805d9d0e..b4966968e 100644 --- a/crates/eps/src/ai_editing/writer/paints.rs +++ b/crates/eps/src/ai_editing/writer/paints.rs @@ -103,6 +103,22 @@ impl Writer<'_> { name } + /// The gradient definitions' state, for [`Self::gradient_rollback`]: their count, which ones the + /// art uses, and the next name's number. + pub(super) fn gradient_mark(&self) -> (usize, Vec, u64) { + (self.gradients.len(), self.gradients.iter().map(|d| d.used).collect(), self.next_gradient) + } + + /// The gradient definitions as they were at `mark` ([`Self::gradient_mark`]). + pub(super) fn gradient_rollback(&mut self, mark: (usize, Vec, u64)) { + let (len, used, next) = mark; + self.gradients.truncate(len); + for (d, u) in self.gradients.iter_mut().zip(used) { + d.used = u; + } + self.next_gradient = next; + } + /// `p` as the paint of a fill or stroke whose gradient is placed on `bounds`: a gradient is /// placed there (it then no longer depends on the shape it paints, which may be cut); one the /// data can't hold is its average color, with a warning. @@ -222,24 +238,11 @@ impl Writer<'_> { /// last stop; a radial one's `… Bm` (the unit circle onto its extent ellipse). fn instance(&mut self, g: &GradientPaint, stroke: bool, clip: bool, path: &BezPath) { let Some(geom) = g.geom else { return }; - let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); self.line(if stroke { "1 Bb" } else { "Bb" }); + self.instance_head(g, clip, path); let m = self.grad_space; let u = geom.end - geom.start; let radial = g.gradient.kind == GradientKind::Radial; - let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); - if radial { - let c = m * geom.start; - let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); - let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; - let off = if fraction > 0.0 { off } else { Vec2::ZERO }; - ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); - self.line("Bh"); - } - let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); - ps::push_nums(&mut self.body, &place.bg); - ps::push_nums(&mut self.body, &place.transform.as_coeffs()); - self.line("1 Bg"); // The unit vector along the gradient, and across it as long. let frame = |from: f64, to: f64| { let p = geom.start + u * from; @@ -267,6 +270,28 @@ impl Writer<'_> { matrix(frame(last, last + CAP / len), "Bc"); } + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `Bg` line, as + /// [`Self::instance`] writes them; also what a fill's art style holds after its `Bb`. + pub(super) fn instance_head(&mut self, g: &GradientPaint, clip: bool, path: &BezPath) { + let Some(geom) = g.geom else { return }; + let name = self.define_gradient(&g.gradient, g.swatch.as_deref()); + let m = self.grad_space; + let radial = g.gradient.kind == GradientKind::Radial; + let place = placement(&geom, radial, &(self.canvas * path.clone()), self.canvas); + if radial { + let c = m * geom.start; + let off = geom.focal.map_or(Vec2::ZERO, |f| m * f - c); + let (angle, fraction) = if off.hypot() > 1e-9 { place.focal } else { (0.0, 0.0) }; + let off = if fraction > 0.0 { off } else { Vec2::ZERO }; + ps::push_nums(&mut self.body, &[off.x, off.y, angle, fraction]); + self.line("Bh"); + } + let _ = write!(self.body, "{} {} ", u8::from(!clip), ps::bytes(name.as_bytes())); + ps::push_nums(&mut self.body, &place.bg); + ps::push_nums(&mut self.body, &place.transform.as_coeffs()); + self.line("1 Bg"); + } + /// A gradient definition: `%AI5_BeginGradient: (name)`, `(name) type stops Bd` (type 0 linear, /// 1 radial), `[`, each stop on a `%_` line (`color style opacity 6 midpoint rampPoint Bs`), a /// linear gradient's from the last to the first and a radial one's from the first to the last, @@ -341,9 +366,10 @@ impl Writer<'_> { /// Can pattern `name` be written as a definition and painted with from here: the document has /// it, its tiles make a grid, its art is within its tile and holds something, and it isn't used - /// in another pattern's tile or nested more than [`MAX_NEST`] deep? + /// in another pattern's tile or a symbol's art (the reader keeps no symbol whose art paints with + /// a pattern) or nested more than [`MAX_NEST`] deep? pub(super) fn pattern_writable(&self, name: &str) -> bool { - if !self.defining.is_empty() || self.nest >= MAX_NEST { + if !self.defining.is_empty() || self.nest >= MAX_NEST || self.in_symbol { return false; } if self.patterns.iter().any(|p| p.name == name) { @@ -389,9 +415,12 @@ impl Writer<'_> { self.line("n\n%_/ArtDictionary :\n%_1 /Bool (AIPattern_Editor_Backing_Tile_Rect) ,\n%_;\n%_"); self.nest += 1; self.hidden += 1; + // The tile's effects are its own, not those of a drawn look that first uses it. + let in_look = std::mem::take(&mut self.in_look); for a in &def.art { self.node(a); } + self.in_look = in_look; self.hidden -= 1; self.nest -= 1; let art; diff --git a/crates/eps/src/ai_editing/writer/styles.rs b/crates/eps/src/ai_editing/writer/styles.rs new file mode 100644 index 000000000..ace7e6b0f --- /dev/null +++ b/crates/eps/src/ai_editing/writer/styles.rs @@ -0,0 +1,684 @@ +//! Art styles: objects with live effects a plain path can't hold, written as their drawn look, the +//! object itself on `%_` lines and `1 (style) XW`, and the styles they name in +//! `%AI9_BeginArtStyles` (see [`crate::ai_editing`] for the form). Each style is read back before it +//! is written ([`crate::import::read_art_style`]: the `.ai` reader's style parser and its reading of +//! a graphic style, with the object's own gradient): one that reads back differently is not +//! written, and the object is written as its drawn look alone. + +use std::collections::HashMap; +use std::fmt::Write; +use std::sync::Arc; + +use serde_json::Value; +use vectorcraft_color::{BlendMode, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Knockout, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_geom::{BezPath, FillRule, Rect, Shape}; + +use super::{Writer, Xy, blend_code, compound_path, hide}; +use crate::ai_ops; +use crate::ps; + +pub(in crate::ai_editing) const LARGE_CANVAS_STYLES: &str = "on a large canvas, no art styles or graphic styles are written: objects with live effects are written with their geometry effects applied to the artwork, where they can no longer be edited, and their other effects are left out of the editing data for other apps"; +pub(in crate::ai_editing) const GRAPHIC_STYLES: &str = "graphic styles and links to them are left out of the editing data for other apps"; + +/// Does `doc` have graphic styles other than a new document's, or an object in its layers, opacity +/// masks or symbols linked to a graphic style? +pub(in crate::ai_editing) fn has_graphic_styles(doc: &Document) -> bool { + fn linked(n: &Node) -> bool { + n.extras().graphic_style.is_some() + || n.extras().mask.as_ref().is_some_and(|m| linked(&m.art)) + || n.children().is_some_and(|c| c.iter().any(|m| linked(m))) + } + doc.graphic_styles != Document::new(1.0, 1.0).graphic_styles || doc.layers.iter().any(|l| linked(l)) || doc.symbols.iter().any(|s| linked(&s.art)) +} + +/// The objects of `doc`'s layers that may be written with an art style ([`Writer::styled_object`]), +/// by id: paths and compound paths with points and groups, with live effects, without Opacity & +/// Mask Define Knockout Shape (the reader takes an object's transparency from its look, which +/// doesn't carry it). A group's members are paths, compound paths or such groups, none with effects +/// or anything baking changes. +pub(in crate::ai_editing) fn styled_objects(doc: &Document) -> HashMap> { + fn walk(nodes: &[Arc], out: &mut HashMap>) { + for n in nodes { + if candidate(n) { + out.insert(n.id, n.clone()); + continue; + } + // Baking a container with effects of its own changes its members, which keep their ids: + // they are written as baking left them. + if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &n.kind + && !bakes_members(n) + { + walk(children, out); + } + } + } + let mut out = HashMap::new(); + walk(&doc.layers, &mut out); + out +} + +/// Does container `n` need baking for what it has itself, whatever its members hold? +fn bakes_members(n: &Node) -> bool { + let mut own = n.clone(); + if let Some(children) = own.children_mut() { + children.clear(); + } + vectorcraft_effects::needs_bake(&own) +} + +/// Whether `n` may be written with an art style ([`styled_objects`]). +fn candidate(n: &Node) -> bool { + if n.appearance.effects.is_empty() || n.knockout_shape { + return false; + } + match &n.kind { + NodeKind::Path { path, guide: false, clipping: false, .. } => has_points(path), + NodeKind::Compound { .. } => has_points(&compound_path(n)), + NodeKind::Group { children, clip: false } => !n.is_compound_shape() && children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// Does `path` write anything ([`Writer::plain_object`] skips subpaths without points)? +fn has_points(path: &vectorcraft_geom::PathData) -> bool { + path.subpaths.iter().any(|sp| !sp.anchors.is_empty()) +} + +/// A member of a group written with an art style: written as it is, and read back as it is. +fn plain_member(n: &Node) -> bool { + let quiet = n.appearance.effects.is_empty() && n.appearance.items.iter().all(|i| i.effects().is_empty()) && !vectorcraft_effects::needs_bake(n); + quiet + && match &n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => true, + NodeKind::Group { children, .. } => children.iter().all(|c| plain_member(c)), + _ => false, + } +} + +/// The art styles objects name, each with its text after its name (`/ActiveStyle :` to `/Def ;`). +#[derive(Default)] +pub(in crate::ai_editing) struct Styles { + /// Every style, in the order written: its name and text. + known: Vec<(String, String)>, + /// The name of each style by its text. + by_text: HashMap, + next_anon: u64, +} + +impl Styles { + /// The name of an object style of `text`: a style with the same text already written, else a + /// new one named `Anon` and a number. + fn object_style(&mut self, text: String) -> String { + if let Some(name) = self.by_text.get(&text) { + return name.clone(); + } + self.next_anon += 1; + let name = format!("Anon {}", self.next_anon); + self.by_text.insert(text.clone(), name.clone()); + self.known.push((name.clone(), text)); + name + } +} + +/// One style as the reader's section holds it: `/KnownStyle :`, `(name) /Name ,`, its text. +fn known_style(name: &str, text: &str) -> String { + format!("/KnownStyle :\n{} /Name ,\n{text}", ps::bytes(name.as_bytes())) +} + +/// The number transparency's `Xy` gives the blending mode a live effect's `mode` names (any case, +/// spaces and dashes ignored). +fn mode_code(mode: &str) -> Option { + let key: String = mode.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase(); + BlendMode::ALL.iter().find(|m| format!("{m:?}").to_ascii_lowercase() == key).map(|m| blend_code(*m)) +} + +/// A filter's `Xy` line: `mode opacity isolate knockout shape Xy`. +fn xy_line(s: &mut String, xy: Xy) { + let knockout = match xy.knockout { + Knockout::Off => 0, + Knockout::On => 1, + Knockout::Neutral => 2, + }; + let _ = write!(s, "{} ", blend_code(xy.blend)); + ps::push_num(s, f64::from(xy.opacity)); + let _ = writeln!(s, " {} {knockout} {} Xy", u8::from(xy.isolate), u8::from(xy.shape)); +} + +/// The line style of a paint style that isn't a stroke's. +const NO_LINE: &str = "0 J 0 j 1 w 10 M []0 d\n"; + +/// A basic filter's start: its name and kind numbers (`1 0` for an effect, `0 0` for the +/// style's own filters), `/Visible` and the dictionary's start. +fn basic(s: &mut String, name: &str, effect: bool, visible: bool) { + let _ = write!(s, "/BasicFilter :\n{} {} 0 /Filter ,\n{} /Visible ,\n", ps::bytes(name.as_bytes()), u8::from(effect), u8::from(visible)); +} + +/// The filter that stands for the object's contents in its stack. +const CONDUIT: &str = "/BasicFilter :\n(Conduit Filter) 0 0 /Filter ,\n1 /Visible ,\n;\n /Part ,\n"; + +/// A basic filter's end. +const END_BASIC: &str = "; /Dict ;\n /Part ,\n"; + +/// The blend filter of transparency `xy`. +fn blend_filter(s: &mut String, xy: Xy) { + basic(s, "Blend Style Filter", false, true); + s.push_str("/Dictionary : /NotRecorded ,\n/BlendStyle : "); + xy_line(s, xy); + s.push_str(NO_LINE); + s.push_str("0 XR\n/Def ; \n (BlendStyle) ,\n"); + s.push_str(END_BASIC); +} + +/// A live effect's dictionary entries. +#[derive(Default)] +struct Entries(String); + +impl Entries { + fn real(&mut self, key: &str, v: f64) { + ps::push_num(&mut self.0, v); + let _ = writeln!(self.0, " /Real {} ,", ps::bytes(key.as_bytes())); + } + + fn int(&mut self, key: &str, v: i64) { + let _ = writeln!(self.0, "{v} /Int {} ,", ps::bytes(key.as_bytes())); + } + + fn bool(&mut self, key: &str, v: bool) { + let _ = writeln!(self.0, "{} /Bool {} ,", u8::from(v), ps::bytes(key.as_bytes())); + } +} + +/// `p`'s entry `key` as a number. +fn num(p: &Value, key: &str) -> Option { + p.get(key)?.as_f64().filter(|v| v.is_finite()) +} + +/// `p`'s entry `key` as text, for comparing: lower case, letters and digits only. +fn word(p: &Value, key: &str) -> Option { + Some(p.get(key)?.as_str()?.chars().filter(char::is_ascii_alphanumeric).collect::().to_ascii_lowercase()) +} + +/// `p`'s entry `key` as a switch. +fn flag(p: &Value, key: &str) -> Option { + p.get(key)?.as_bool() +} + +impl Writer<'_> { + /// What `paint` writes with [`Self::color`] (a color, a spot or global color), as a string. + fn color_ops(&mut self, paint: &Paint, stroke: bool) -> String { + let saved = std::mem::take(&mut self.body); + self.color(paint, stroke); + std::mem::replace(&mut self.body, saved) + } + + /// A fill style holding a color, as an effect's entry `key` or a fill's own. + fn fill_style(&mut self, s: &mut String, color: vectorcraft_color::Color, key: &str) { + let ops = self.color_ops(&Paint::solid(color), false); + let _ = write!(s, "/FillStyle : 0 O\n{ops}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n/Def ; \n {} ,\n", ps::bytes(key.as_bytes())); + } + + /// A fill's basic filter: a color, or a gradient placed on the object's outline (`on`: the + /// object's own outline and rule), filling by the non-zero rule. `None` for other paint. + fn fill_filter(&mut self, s: &mut String, f: &FillLayer, visible: bool, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if f.overprint { + return None; + } + // A color comes before the paint state, a gradient's instance after it. + let (color, gradient) = match &f.paint { + Paint::Solid { .. } => (self.color_ops(&f.paint, false), String::new()), + Paint::Gradient(_) => { + let (path, _) = on?; + let Paint::Gradient(placed) = self.placed(&f.paint, path.bounding_box()) else { return None }; + (String::new(), format!("Bb\n{}0 BB\n", self.gradient_bg(&placed, path)?)) + } + Paint::Pattern { .. } | Paint::None => return None, + }; + basic(s, "Fill Style Filter", false, visible); + let _ = write!( + s, + "/Dictionary : /NotRecorded ,\n0 /Bool (UseEvenOdd) ,\n/FillStyle : 0 O\n{color}0 1 0 0 0 Xy\n{NO_LINE}0 XR\n0 1 Xd\n{gradient}" + ); + s.push_str("/Def ; \n (FillStyle) ,\n"); + s.push_str(END_BASIC); + Some(()) + } + + /// A stroke's basic filter: a color, its line style, and its width profile as the arrays + /// `BSVariableWidthTValuesArray`, `BSLeftWidthsArray` and `BSRightWidthsArray`. `None` for + /// other paint. + fn stroke_filter(&mut self, s: &mut String, st: &StrokeLayer, visible: bool) -> Option<()> { + if !matches!(st.paint, Paint::Solid { .. }) || st.overprint { + return None; + } + let ops = self.color_ops(&st.paint, true); + basic(s, "Stroke Style Filter", false, visible); + s.push_str("2 /FillOrStroke ,\n/Dictionary : /NotRecorded ,\n/StrokeStyle : 0 R\n"); + s.push_str(&ops); + s.push_str("0 1 0 0 0 Xy\n"); + ai_ops::line_style(s, st); + s.push_str("0 XR\n1 0 Xd\n/Def ; \n (StrokeStyle) ,\n"); + if let Some(p) = &st.profile { + let array = |s: &mut String, key: &str, values: &mut dyn Iterator| { + s.push_str("/Array :"); + for v in values { + s.push(' '); + ps::push_num(s, v); + s.push_str(" /Real ,"); + } + let _ = writeln!(s, " ; {} ,", ps::bytes(key.as_bytes())); + }; + array(s, "BSVariableWidthTValuesArray", &mut p.points.iter().map(|q| q.0)); + array(s, "BSLeftWidthsArray", &mut p.points.iter().map(|q| q.1)); + array(s, "BSRightWidthsArray", &mut p.points.iter().map(|q| q.2)); + } + s.push_str(END_BASIC); + Some(()) + } + + /// A gradient instance's focal point (`Bh`, radial gradients only) and its `flag (name) x y + /// angle length a b c d tx ty 1 Bg` line for placed gradient `g` on `path`, as a fill's style + /// holds them ([`Self::instance_head`]). + fn gradient_bg(&mut self, g: &vectorcraft_color::GradientPaint, path: &BezPath) -> Option { + let saved = std::mem::take(&mut self.body); + self.instance_head(g, false, path); + let line = std::mem::replace(&mut self.body, saved); + (!line.is_empty()).then_some(line) + } + + /// An appearance item's basic filter. `None` for an item with transparency or effects of its + /// own, which the reader doesn't read on an object. + fn item_filter(&mut self, s: &mut String, item: &AppearanceItem, on: Option<(&BezPath, FillRule)>) -> Option<()> { + if item.opacity() < 1.0 || item.blend() != BlendMode::Normal || !item.effects().is_empty() { + return None; + } + match item { + AppearanceItem::Fill(f) => self.fill_filter(s, f, item.visible(), on), + AppearanceItem::Stroke(st) => self.stroke_filter(s, st, item.visible()), + } + } + + /// The text of a style of appearance `ap` and transparency `xy` (after its name): the chain + /// of its stack (the fills and strokes that paint, the top one first, the contents among them + /// where the appearance puts them), its effects and its blend filter. A gradient fill is + /// placed on the outline of `on`, and a fill fills by its rule (`None`: the non-zero rule, on no + /// object). `None` when a fill, stroke or effect isn't one written here. + pub(super) fn style_text(&mut self, ap: &Appearance, xy: Xy, on: Option<(&BezPath, FillRule)>) -> Option { + let mut s = String::from( + "/ActiveStyle :\n/CompoundFilter :\n(Chain Style Filter) 0 0 /Filter ,\n1 /Visible ,\n\ + /CompoundFilter :\n(Stack Style Filter) 0 0 /Filter ,\n1 /Visible ,\n", + ); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let painting: Vec<(usize, &AppearanceItem)> = ap.items.iter().enumerate().filter(|(_, i)| !i.paint().is_none()).collect(); + let below = painting.iter().filter(|(at, _)| *at < ci).count(); + // Listed top first: the contents go above the `below` fills and strokes under them. + for (written, (_, item)) in painting.iter().rev().enumerate() { + if painting.len() - written == below { + s.push_str(CONDUIT); + } + self.item_filter(&mut s, item, on)?; + } + if below == 0 { + s.push_str(CONDUIT); + } + s.push_str(";\n /Part ,\n"); + for e in &ap.effects { + s.push_str(&self.effect_filter(e)?); + } + blend_filter(&mut s, xy); + s.push_str(";\n\n/Execution ;\n /Def ;\n"); + Some(s) + } + + /// A live effect's basic filter, with the settings the reader reads + /// ([`crate::import`]'s `.ai` reader, `style.rs`), in the units it reads them in. `None` for an + /// effect, or a setting, that isn't written here. + fn effect_filter(&mut self, e: &Effect) -> Option { + let p = vectorcraft_effects::merged_params(&e.id, &e.params); + let mut d = Entries::default(); + let id = e.id.as_str(); + let name = if id.starts_with("stylize.") { self.stylize_entries(id, &p, &mut d)? } else { Self::path_entries(id, &p, &mut d)? }; + let mut s = String::new(); + basic(&mut s, name, true, e.visible); + s.push_str("/Dictionary : /NotRecorded ,\n"); + s.push_str(&d.0); + s.push_str(END_BASIC); + Some(s) + } + + /// A Stylize effect's entries ([`Self::effect_filter`]) and its filter's name. + fn stylize_entries(&mut self, id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "stylize.dropShadow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + let darkness = word(p, "shade").as_deref() == Some("darkness"); + if darkness { + d.real("dark", num(p, "darkness")?); + } + d.real("vert", num(p, "y")?); + d.real("horz", num(p, "x")?); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + d.int("csrc", i64::from(darkness)); + "Adobe Drop Shadow" // brand-ok: the filter's name in the files + } + "stylize.innerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")?); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::WHITE); + self.fill_style(&mut d.0, color, "gclr"); + d.int("gtyp", i64::from(word(p, "source").as_deref() == Some("edge"))); + "Adobe Inner Glow" // brand-ok: the filter's name in the files + } + "stylize.outerGlow" => { + d.int("blnd", i64::from(mode_code(p.get("mode")?.as_str()?)?)); + d.real("opac", num(p, "opacity")? / 100.0); + d.real("blur", num(p, "blur")? / 2.0); + let color = vectorcraft_effects::color_param(p, vectorcraft_color::Color::BLACK); + self.fill_style(&mut d.0, color, "sclr"); + "Adobe Outer Glow" // brand-ok: the filter's name in the files + } + "stylize.feather" => { + d.real("Radius", num(p, "radius")?); + "Adobe Fuzzy Mask" // brand-ok: the filter's name in the files + } + "stylize.roundCorners" => { + d.real("radius", num(p, "radius")?); + "Adobe Round Corners" // brand-ok: the filter's name in the files + } + "stylize.scribble" => { + d.real("Angle", num(p, "angle")?); + d.real("EdgeOverlap", num(p, "overlap")?); + d.real("EdgeOverlapVariation", num(p, "overlapVariation")?); + d.real("StrokeWidth", num(p, "strokeWidth")?); + d.real("Scribbliness", num(p, "curviness")? / 100.0); + d.real("ScribbleVariation", num(p, "curvinessVariation")? / 100.0); + d.real("Spacing", num(p, "spacing")?); + d.real("SpacingVariation", num(p, "spacingVariation")?); + "Adobe Scribble Fill" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// Convert to Shape's, a Path effect's or Pathfinder › Add's entries ([`Self::effect_filter`]) + /// and its filter's name. + fn path_entries(id: &str, p: &Value, d: &mut Entries) -> Option<&'static str> { + Some(match id { + "convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => { + let shape = match id { + "convertToShape.rectangle" => 0, + "convertToShape.roundedRectangle" => 1, + _ => 2, + }; + let relative = flag(p, "relative")?; + d.int("Shape", shape); + d.real("Absolute", if relative { 0.0 } else { 1.0 }); + d.real("AbsWidth", num(p, "width")?); + d.real("AbsHeight", num(p, "height")?); + d.real("RelWidth", num(p, "extraW")? / 2.0); + d.real("RelHeight", num(p, "extraH")? / 2.0); + if shape == 1 { + d.real("CornerRadius", num(p, "radius")?); + } + "Adobe Shape Effects" // brand-ok: the filter's name in the files + } + "path.offsetPath" => { + d.real("ofst", num(p, "offset")?); + let joins = match word(p, "joins")?.as_str() { + "round" => 0, + "bevel" => 1, + "miter" => 2, + _ => return None, + }; + d.int("jntp", joins); + d.real("mlim", num(p, "miterLimit")?); + "Adobe Offset Path" // brand-ok: the filter's name in the files + } + "path.outlineObject" => "Adobe Outline Type", // brand-ok: the filter's name in the files + "path.outlineStroke" => "Adobe Outline Stroke", // brand-ok: the filter's name in the files + "pathfinder.add" => { + d.int("Command", 0); + d.bool("ConvertCustom", true); + d.bool("ExtractUnpainted", true); + d.real("Mix", 0.5); + d.real("Precision", 10.0); + d.bool("RemovePoints", false); + d.real("TrapAspect", 1.0); + d.bool("TrapConvertCustom", false); + d.real("TrapMaxTint", 1.0); + d.bool("TrapReverse", false); + d.real("TrapThickness", 0.25); + d.real("TrapTint", 0.4); + d.real("TrapTintTolerance", 0.05); + "Adobe Pathfinder" // brand-ok: the filter's name in the files + } + _ => return None, + }) + } + + /// The style object `orig` (`n` before baking) is written with; `None` when the reader wouldn't + /// read the object as it is with it. A gradient fill is placed on `n`'s outline, as the object + /// draws it. A style that isn't written leaves no gradient definition or warning behind. + pub(super) fn object_style(&mut self, n: &Node, orig: &Node) -> Option { + if let NodeKind::Group { children, .. } = &orig.kind + && bakes_members(orig) + && !children.iter().all(|c| self.member_keeps_outline(c)) + { + return None; + } + let graded = orig.appearance.items.iter().any(|i| matches!(i.paint(), Paint::Gradient(_))); + let saved = (graded.then(|| self.gradient_mark()), self.warnings.len()); + let path = drawn_outline(n, orig); + let rule = object_rule(orig); + let text = self.style_text(&orig.appearance, Xy::of(orig), path.as_ref().map(|p| (p, rule))).filter(|t| object_reads(orig, t)); + let Some(text) = text else { + if let Some(mark) = saved.0 { + self.gradient_rollback(mark); + } + self.warnings.truncate(saved.1); + return None; + }; + Some(self.styles.object_style(text)) + } + + /// Is member `m` of a styled group whose effects change its members' outlines (and each of its + /// own members) written on the outline those effects draw on: every fill and stroke as the + /// object paints it ([`Self::direct`], [`Self::direct_stroke`]), not as filled outlines, brush + /// art or pattern tiles, and no gradient that fits its object, which the effects would fit to + /// the new outline? + fn member_keeps_outline(&self, m: &Node) -> bool { + let items_keep = m.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => f.paint.is_none() || self.direct(&f.paint), + AppearanceItem::Stroke(s) => s.paint.is_none() || self.direct_stroke(s), + }); + items_keep && m.children().is_none_or(|c| c.iter().all(|c| self.member_keeps_outline(c))) + } + + /// Object `n` (as baking left it; `orig` before baking) with the style `name`: its lock and + /// hidden state; its drawn look (a group of its vector art as baking left it, without shadows, + /// glows or feathers, the object's transparency + /// after the group's end, `0 0 Xd`, `6 () XW`); the object itself on `%_` lines (a path's or + /// compound path's points painted with nothing, or with its gradient fill when its style has + /// one; a group with its members), its name and opacity mask; then `1 (name) XW`. + pub(super) fn styled_object(&mut self, n: &Node, orig: &Node, name: &str) { + self.state(orig.locked, !orig.visible); + let xy = Xy::of(orig); + let was = self.in_look; + self.in_look = true; + self.line("u"); + let mut look = n.clone(); + (look.name, look.opacity, look.blend, look.isolate, look.knockout, look.knockout_shape) = + (None, 1.0, BlendMode::Normal, false, Knockout::Neutral, false); + if look.extras().mask.is_some() { + look.extras_mut().mask = None; + } + match &look.kind { + NodeKind::Group { children, clip: false } => { + for c in children.clone() { + self.node(&c); + } + } + _ => self.node(&look), + } + self.line("U"); + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + self.in_look = was; + // The object, on `%_` lines, in its own state (the look's members may have changed it). + self.state(orig.locked, !orig.visible); + let saved = std::mem::take(&mut self.body); + self.hidden += 1; + let fit = drawn_outline(n, orig).map(|p| p.bounding_box()); + match &orig.kind { + NodeKind::Path { path, rule, .. } => self.styled_outline(orig, path, *rule, fit, xy), + NodeKind::Compound { rule, .. } => self.styled_outline(orig, &compound_path(orig), *rule, fit, xy), + NodeKind::Group { children, .. } => { + self.line("u"); + for c in children { + self.node(c); + } + self.line("U"); + } + _ => {} + } + self.dictionary(orig); + self.hidden -= 1; + let part = std::mem::replace(&mut self.body, saved); + hide(&part, &mut self.body); + self.block_end = usize::MAX; + let _ = writeln!(self.body, "1 {} XW", ps::bytes(name.as_bytes())); + } + + /// A styled path's or compound path's points: painted with nothing, or with its gradient fill + /// (which its style's gradient fill is), placed on `fit` (the outline's bounds when `None`). + fn styled_outline(&mut self, orig: &Node, path: &vectorcraft_geom::PathData, rule: vectorcraft_geom::FillRule, fit: Option, xy: Xy) { + let bounds = fit.unwrap_or_else(|| path.to_bezpath().bounding_box()); + let gradient = orig.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if matches!(f.paint, Paint::Gradient(_)) => Some(f), + _ => None, + }); + match gradient { + Some(f) => { + let fill = FillLayer { paint: self.placed(&f.paint, bounds), ..f.clone() }; + self.plain_object(path, rule, Some(&fill), None, xy.object()); + } + None => self.plain_object(path, rule, None, None, xy.object()), + } + } + + /// The art styles written, in a non-printing block: `%AI9_BeginArtStyles`, each style, + /// `%AI9_EndArtStyles`. + pub(in crate::ai_editing) fn art_styles(&self, s: &mut String) { + if self.styles.known.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI9_BeginArtStyles\n"); + for (name, text) in &self.styles.known { + s.push_str(&known_style(name, text)); + } + s.push_str("%AI9_EndArtStyles\n%AI5_End_NonPrinting--\n"); + } +} + +/// The outline a path's or compound path's style places a gradient fill on. +fn object_outline(n: &Node) -> Option { + match &n.kind { + NodeKind::Path { path, .. } => Some(path.to_bezpath()), + NodeKind::Compound { .. } => Some(compound_path(n).to_bezpath()), + _ => None, + } +} + +/// The outline a styled path or compound path draws: `n`'s, as baking left it, when it is still a +/// path or compound path, else `orig`'s. +fn drawn_outline(n: &Node, orig: &Node) -> Option { + object_outline(orig)?; + object_outline(n).or_else(|| object_outline(orig)) +} + +/// The fill rule of a path or compound path (a group's is the default). +fn object_rule(n: &Node) -> FillRule { + match &n.kind { + NodeKind::Path { rule, .. } | NodeKind::Compound { rule, .. } => *rule, + _ => FillRule::NonZero, + } +} + +/// Does the reader read object `n` written with style text `text` as it is: a path or compound +/// path with a style it reads on any object, filled by the non-zero rule when the style fills it, +/// or a group with no fills or strokes of its own? +fn object_reads(n: &Node, text: &str) -> bool { + let section = known_style("x", text); + let own = n.appearance.fill().map(|f| &f.paint).filter(|p| matches!(p, Paint::Gradient(_))); + let Some(mut read) = crate::import::read_art_style(section.as_bytes(), "x", own) else { return false }; + let group = matches!(n.kind, NodeKind::Group { .. }); + if !read.simple || (group && !read.appearance.items.is_empty()) || (!group && read.pathfinder) { + return false; + } + if !group { + let filled = n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(_)) && !i.paint().is_none()); + if filled && object_rule(n) == FillRule::EvenOdd { + return false; + } + // A path's contents are nothing: the reader keeps the object's own. + read.appearance.contents_index = n.appearance.contents_index; + } + same_appearance(&read.appearance, &n.appearance) +} + +/// Do appearances `a` and `b` draw the same, as the reader reads one written from the other: the +/// fills and strokes that paint, within the written numbers' precision, and the effects with +/// their settings filled in from their defaults (blending modes in any case, colors as colors). +pub(in crate::ai_editing) fn same_appearance(a: &Appearance, b: &Appearance) -> bool { + let norm = |ap: &Appearance| -> Value { + let mut ap = ap.clone(); + let ci = ap.contents_index.unwrap_or(0).min(ap.items.len()); + let below = ap.items.iter().take(ci).filter(|i| !i.paint().is_none()).count(); + ap.items.retain(|i| !i.paint().is_none()); + ap.contents_index = (below > 0).then_some(below); + for e in ap.effects.iter_mut().chain(ap.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + e.params = normalized_params(e); + } + serde_json::to_value(&ap).unwrap_or(Value::Null) + }; + near(&norm(a), &norm(b)) +} + +/// Effect `e`'s settings with its defaults filled in, its blending mode in lower case and its color as +/// `#rrggbb`. +fn normalized_params(e: &Effect) -> Value { + let mut p = vectorcraft_effects::merged_params(&e.id, &e.params); + if let Some(m) = word(&p, "mode") { + p["mode"] = Value::String(m); + } + if p.get("color").is_some() { + p["color"] = Value::String(vectorcraft_effects::color_param(&p, vectorcraft_color::Color::BLACK).to_hex()); + } + for key in ["shade", "source", "joins", "points", "orientation"] { + if let Some(w) = word(&p, key) { + p[key] = Value::String(w); + } + } + p +} + +/// How far apart two numbers may read in [`near`]: a number written to 4 decimals, then converted +/// from a fraction to a percentage (`5e-3`) or from radians to degrees (`3e-3`). +const NEAR: f64 = 6e-3; + +/// JSON values equal but for numbers within the written numbers' precision. +fn near(a: &Value, b: &Value) -> bool { + match (a, b) { + (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) { + (Some(x), Some(y)) => (x - y).abs() <= NEAR, + _ => x == y, + }, + (Value::Array(x), Value::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(x, y)| near(x, y)), + (Value::Object(x), Value::Object(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| near(v, w))), + _ => a == b, + } +} diff --git a/crates/eps/src/ai_editing/writer/symbols.rs b/crates/eps/src/ai_editing/writer/symbols.rs new file mode 100644 index 000000000..ccec95d42 --- /dev/null +++ b/crates/eps/src/ai_editing/writer/symbols.rs @@ -0,0 +1,339 @@ +//! Symbols: their definitions, the symbol list and their instances (see [`crate::ai_editing`] for +//! the form). + +use std::collections::HashSet; +use std::fmt::Write; + +use vectorcraft_color::Paint; +use vectorcraft_doc::{Knockout, Node, NodeKind, SymbolExportType}; +use vectorcraft_geom::Affine; + +use super::{MAX_NEST, Writer, Xy}; +use crate::import::MAX_SYMBOLS; +use crate::ps; + +pub(in crate::ai_editing) const SYMBOL_ART: &str = "symbol instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, instances that scale their symbol more than 100 times, instances on the layers of a large canvas and instances of symbols past 4,096 are written as their art in the editing data for other apps"; +pub(in crate::ai_editing) const SYMBOL_OPTIONS: &str = + "the 9-slice guides, export types and dynamic type of symbols are left out of the editing data for other apps"; +pub(in crate::ai_editing) const MISSING_SYMBOL: &str = + "symbol instances of symbols the document doesn't have are written as empty groups in the editing data for other apps"; +/// The most an instance may scale its symbol's art, nested instances' scales included: the art's +/// numbers are written to four decimals, so a scale of 100 places them within 0.005 pt. +const MAX_SCALE: f64 = 100.0; +pub(in crate::ai_editing) const SYMBOL_COUNT: &str = "symbols past 4,096 are left out of the editing data for other apps"; + +/// Whether the canvas works `n` itself (not its children) out again after placing an instance: +/// type, live objects and shapes with rounded live corners, whose strokes, steps and corners depend +/// on where the instance puts them. +pub(super) fn redrawn(n: &Node) -> bool { + let rounded = |l: &vectorcraft_doc::LiveShape| match l { + vectorcraft_doc::LiveShape::Rectangle { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Polygon { radii, .. } | vectorcraft_doc::LiveShape::Path { radii, .. } => radii.iter().any(|r| *r > 0.0), + vectorcraft_doc::LiveShape::Ellipse { .. } | vectorcraft_doc::LiveShape::Line { .. } => false, + }; + match &n.kind { + NodeKind::Path { live: Some(l), .. } => rounded(l), + NodeKind::Text(_) + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + | NodeKind::PlacedDocument(_) + | NodeKind::Intertwine { .. } => true, + _ => false, + } +} + +impl Writer<'_> { + /// Whether instance `n` of symbol `symbol`, placed by `xf`, is written as an instance: not on the + /// layers of a large canvas, in a pattern's tile or in art cut to a clipping object, without a + /// fill or appearance changes of its own (which stain or change the art it draws) and without + /// isolation, knockout or knockout shape of its own (which an instance's transparency doesn't + /// hold), of a symbol without 9-slice guides and without pattern paint in its art (the canvas + /// keeps an instance's pattern tiles where the document puts them, and a definition's art moves + /// with each instance) whose definition can be written ([`Self::define_symbol`]), by a matrix + /// that keeps the art's area as it is written and scales it as the definition allows + /// ([`Self::keeps_scale`]). + pub(super) fn live_instance(&mut self, n: &Node, symbol: &str, xf: Affine) -> bool { + let own = vectorcraft_brush::stain(n).is_some() || !n.extras().symbol_overrides.is_empty(); + let group_state = n.isolate || n.knockout != Knockout::Neutral || n.knockout_shape; + // The matrix as written (to ten decimals) must still keep the art's area. + let m = Affine::new((self.instance_space * xf).as_coeffs().map(|v| (v * 1e10).round() / 1e10)); + (self.symbols_live || self.in_symbol) + && self.defining.is_empty() + && self.clip.is_none() + && !own + && !group_state + && self.doc.symbol_nine_slice(symbol).is_none() + && m.as_coeffs().iter().all(|v| v.is_finite()) + && m.determinant().abs() >= 1e-12 + && self.define_symbol(symbol) + && !self.symbol_paints_pattern(symbol) + && self.keeps_scale(symbol, xf) + } + + /// Whether instance matrix `xf` places symbol `symbol`'s definition as the canvas draws the + /// instance. A definition holding art the canvas works out again after placing an instance + /// ([`redrawn`], strokes written as outlines or brush art, and instances written as their art) + /// takes only matrices that move it; + /// any other takes a scale up to [`MAX_SCALE`], nested instances' scales included. A live + /// instance in a symbol's art passes both to that symbol. + fn keeps_scale(&mut self, symbol: &str, xf: Affine) -> bool { + let [a, b, c, d, ..] = xf.as_coeffs(); + let fixed = self.fixed_symbols.contains(symbol); + let scale = a.hypot(b).max(c.hypot(d)) * self.symbol_scales.get(symbol).copied().unwrap_or(1.0); + let keeps = if fixed { (a - 1.0).abs() < 1e-9 && b.abs() < 1e-9 && c.abs() < 1e-9 && (d - 1.0).abs() < 1e-9 } else { scale <= MAX_SCALE }; + if keeps && self.in_symbol { + self.wrote_fixed |= fixed; + self.inner_scale = self.inner_scale.max(scale); + } + keeps + } + + /// Whether the art of symbol `name`, an opacity mask in it or the art of a symbol it holds an + /// instance of, at any depth, fills or strokes something (type included) with a pattern, or its + /// definition holds pattern tiles. Worked out once a symbol, after it is defined. + fn symbol_paints_pattern(&mut self, name: &str) -> bool { + if self.pattern_written.contains(name) { + return true; + } + if let Some(known) = self.patterned_symbols.get(name) { + return *known; + } + let mut seen = HashSet::from([name.to_string()]); + let mut todo = vec![name.to_string()]; + let mut found = false; + while let Some(next) = todo.pop() { + let (own, used) = self.symbol_own_patterns(&next); + if own { + found = true; + break; + } + for u in used { + if seen.insert(u.clone()) { + todo.push(u); + } + } + } + self.patterned_symbols.insert(name.to_string(), found); + found + } + + /// Whether the art of symbol `name` or an opacity mask in it fills or strokes something (type + /// included) with a pattern, and the symbols its instances and inline graphics there use; looked + /// at once a symbol. + fn symbol_own_patterns(&mut self, name: &str) -> (bool, Vec) { + if let Some(known) = self.pattern_symbols.get(name) { + return known.clone(); + } + let doc = self.symbol_doc; + let mut own = false; + let mut used = vec![]; + if let Some(sym) = doc.symbols.iter().find(|s| s.name == name) { + let pattern = |p: &Paint| matches!(p, Paint::Pattern { .. }); + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + own |= vectorcraft_doc::pattern::uses_pattern(m, None); + match &m.kind { + NodeKind::Text(t) => { + own |= t.runs.iter().any(|r| pattern(&r.style.fill) || pattern(&r.style.stroke)); + // Inline graphics are written as their symbols' art. + used.extend(t.runs.iter().filter_map(|r| r.inline.as_ref()).map(|art| art.symbol.clone())); + } + NodeKind::SymbolInstance { symbol, .. } => used.push(symbol.clone()), + _ => {} + } + }); + } + self.pattern_symbols.insert(name.to_string(), (own, used.clone())); + (own, used) + } + + /// An instance of `symbol` placed by `xf`, of transparency `xy`: `/SymbolInstance :`, `(name) + /// /SymbolRef ,`, the matrix taking the symbol's art to art space (in a symbol's art, to the + /// canvas's space turned y up about art space's origin there) as `a b c d tx ty + /// /RTransformMatrix` (to ten decimals), `;`, then its state block as a group's: `Xy`, `0 0 Xd` + /// and `6 () XW` when it has transparency, else `9 () XW`. Its dictionary goes between the two. + pub(super) fn symbol_instance(&mut self, symbol: &str, xf: Affine, xy: Xy) { + let m = self.instance_space * xf; + let _ = write!(self.body, "/SymbolInstance :\n{} /SymbolRef ,\n", ps::bytes(symbol.as_bytes())); + for v in m.as_coeffs() { + self.body.push_str(&super::images::precise(v)); + self.body.push(' '); + } + self.body.push_str("/RTransformMatrix\n;\n"); + // An instance with isolation, knockout or knockout shape of its own is written as its art + // ([`Self::live_instance`]); these are the values of the others. + let xy = Xy { isolate: false, knockout: Knockout::Neutral, shape: false, ..xy }; + self.block_start = self.body.len(); + if xy == Xy::PLAIN { + self.line("9 () XW"); + } else { + self.write_xy(xy); + self.line("0 0 Xd\n6 () XW"); + } + self.block_end = self.body.len(); + } + + /// Write symbol `name`'s definition, once: `%AI14_BeginSymbol`, `(name)`, `0 A`, `0 Xw`, `0 Ae`, + /// the art in the symbol's space (y down: its numbers are the symbol's own) as a group (`u` … + /// `U`; art that isn't a group, or is a plain group of one object, in a group of its own), the + /// symbol's registration point (`AI13PatternRegistrationType`, 1 to 9 from the top left, row by + /// row) in that group's dictionary, `9 () XW`, `%AI10_EndSymbol`. Linked images in the + /// art are embedded. The symbols its art's instances use are defined first, and the art is + /// written from the top of the nesting whatever the instance that asks for it, so a definition + /// is the same wherever its first instance lies. False when it can't be: the document doesn't + /// have the symbol, it is being written (its art holds an instance of itself), it is reached + /// through more than [`MAX_NEST`] definitions being written, or the definitions written and + /// being written leave no place under [`MAX_SYMBOLS`]. + pub(super) fn define_symbol(&mut self, name: &str) -> bool { + if self.defined_symbols.contains(name) { + return true; + } + let doc = self.symbol_doc; + let Some(sym) = doc.symbols.iter().find(|s| s.name == name) else { return false }; + if self.defining_symbols.iter().any(|d| d == name) || self.defining_symbols.len() >= MAX_NEST as usize { + return false; + } + // The reader keeps no more definitions than this, and each definition being written takes + // one when it ends. + if self.symbol_defs.len() + self.defining_symbols.len() >= MAX_SYMBOLS { + self.warn(SYMBOL_COUNT); + return false; + } + if sym.dynamic || doc.symbol_export_type(name) != SymbolExportType::default() || doc.symbol_nine_slice(name).is_some() { + self.warn(SYMBOL_OPTIONS); + } + self.defining_symbols.push(name.to_string()); + let mut used = vec![]; + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| { + if let NodeKind::SymbolInstance { symbol, .. } = &m.kind { + used.push(symbol.as_str()); + } + }); + for u in used { + self.define_symbol(u); + } + if self.symbol_defs.len() + self.defining_symbols.len() > MAX_SYMBOLS { + self.defining_symbols.pop(); + self.warn(SYMBOL_COUNT); + return false; + } + // The art is written in the symbol's space, from the state `0 A` and `0 Xw` set, from the + // document at its own size. + let origin = self.canvas_origin; + let about_origin = Affine::new([1.0, 0.0, 0.0, -1.0, -origin.0, origin.1]); + let page_doc = std::mem::replace(&mut self.doc, doc); + let saved = ( + std::mem::take(&mut self.body), + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state.replace((false, false)), + self.gs.replace(Xy::OPAQUE), + self.clip.take(), + (self.block_start, self.block_end), + (std::mem::replace(&mut self.in_symbol, true), std::mem::take(&mut self.hidden)), + self.root_entries.take(), + std::mem::take(&mut self.wrote_pattern), + (std::mem::take(&mut self.wrote_fixed), std::mem::replace(&mut self.inner_scale, 1.0)), + ); + // Gradients' matrices and instances' matrices are written about the canvas point of art + // space's origin, y up; `Bg` places a gradient on the art as it lies in the symbol's space. + (self.to_art, self.grad_space, self.canvas, self.instance_space) = (Affine::IDENTITY, about_origin, Affine::IDENTITY, about_origin); + self.block_end = usize::MAX; + let registration = u16::from(doc.symbol_registration(name).0.min(8)) + 1; + let entries = format!("%_{registration} /Int (AI13PatternRegistrationType) ,\n"); + // The canvas draws a symbol's art even when its root is hidden, so the root is written shown. + let shown; + let root: &Node = if sym.art.visible { + &sym.art + } else { + let mut a = (*sym.art).clone(); + a.visible = true; + shown = a; + &shown + }; + // A definition holds a group: art of another kind, and a plain group of one object (which + // the reader takes for that group), are written in one. + let wrap = !matches!(root.kind, NodeKind::Group { clip: false, .. }) || crate::import::symbol_art_member(root).is_some(); + let nest = std::mem::replace(&mut self.nest, 1); + if wrap { + self.line("u"); + self.node(root); + let _ = write!(self.body, "U\n%_/ArtDictionary :\n{entries}%_;\n%_\n9 () XW\n"); + } else { + // A group always writes its dictionary ([`Self::dictionary`]), which takes the entries. + self.root_entries = Some((root.id, entries)); + self.node(root); + } + self.nest = nest; + // Objects the writing doesn't pass through one by one (clipping paths, a clipping group's + // own path) count as well. + vectorcraft_doc::swatches::walk_with_masks(&sym.art, &mut |m| self.wrote_fixed |= redrawn(m)); + if self.wrote_pattern { + self.pattern_written.insert(name.to_string()); + } + if self.wrote_fixed { + self.fixed_symbols.insert(name.to_string()); + } + if self.inner_scale > 1.0 { + self.symbol_scales.insert(name.to_string(), self.inner_scale); + } + let art; + ( + art, + (self.to_art, self.grad_space, self.canvas, self.instance_space), + self.state, + self.gs, + self.clip, + (self.block_start, self.block_end), + (self.in_symbol, self.hidden), + self.root_entries, + self.wrote_pattern, + (self.wrote_fixed, self.inner_scale), + ) = saved; + self.doc = page_doc; + let art = std::mem::replace(&mut self.body, art); + self.defining_symbols.pop(); + let text = format!("%AI14_BeginSymbol\n{}\n0 A\n0 Xw\n0 Ae\n{art}%AI10_EndSymbol\n", ps::bytes(name.as_bytes())); + self.defined_symbols.insert(name.to_string()); + self.symbol_defs.push(text); + true + } + + /// Every symbol of the document defined, in its order (those its art uses before it): the + /// Symbols panel keeps the ones no instance uses too, and on a large canvas all of them. + pub(in crate::ai_editing) fn symbols(&mut self) { + let doc = self.symbol_doc; + for s in &doc.symbols { + self.define_symbol(&s.name); + } + } + + /// The symbol definitions, in the setup after the patterns: `%AI5_Begin_NonPrinting`, `Np`, each + /// definition, `%AI5_End_NonPrinting--`. + pub(in crate::ai_editing) fn symbol_definitions(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n"); + for d in &self.symbol_defs { + s.push_str(d); + } + s.push_str("%AI5_End_NonPrinting--\n"); + } + + /// The symbol list, after the palette: the names of the symbols defined in the document's order + /// (the Symbols panel's), between `%AI24_BeginSymbolList` and `%AI24_EndSymbolList` in a + /// non-printing block. + pub(in crate::ai_editing) fn symbol_list(&self, s: &mut String) { + if self.symbol_defs.is_empty() { + return; + } + s.push_str("%AI5_Begin_NonPrinting\nNp\n%AI24_BeginSymbolList\n"); + for sym in &self.symbol_doc.symbols { + if self.defined_symbols.contains(&sym.name) { + let _ = writeln!(s, "{}", ps::bytes(sym.name.as_bytes())); + } + } + s.push_str("%AI24_EndSymbolList\n%AI5_End_NonPrinting--\n"); + } +} diff --git a/crates/eps/src/import.rs b/crates/eps/src/import.rs index 51c604228..8bc8a174c 100644 --- a/crates/eps/src/import.rs +++ b/crates/eps/src/import.rs @@ -70,7 +70,7 @@ use graphics::{GState, Out}; use interp::{Fault, Interp}; use obj::PsError; -pub(crate) use ai::MAX_GRADIENT_STOPS; +pub(crate) use ai::{MAX_GRADIENT_STOPS, MAX_SYMBOLS, read_art_style, symbol_art_member}; pub(crate) use ate::{MAX_BYTES as MAX_TEXT_DOCUMENT, MAX_FRAMES, MAX_SEGMENTS, MAX_STORY, MAX_VALUES as MAX_TEXT_VALUES}; pub use native::{AiLayers, ai_alone, ai_alone_on_page, ai_layers_alone, ai_opened_artboard, is_loss, layered_ai, layered_ai_from}; pub(crate) use native::{MAX_TEXT, scale_document}; diff --git a/crates/eps/src/import/ai.rs b/crates/eps/src/import/ai.rs index 57d240f9f..5537b5d06 100644 --- a/crates/eps/src/import/ai.rs +++ b/crates/eps/src/import/ai.rs @@ -35,13 +35,16 @@ use super::lex::find; use crate::ps; pub(crate) use envelope::DRAWN as ENVELOPE_DRAWN; +#[cfg(test)] +pub(crate) use envelope::MAX_CHECKED; +pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType}; pub(crate) use frames::text_frames; pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS; pub use read::{ BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read, read_beside, variables, }; -pub(crate) use read::{join_and, slot_frame, slot_of}; +pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member}; pub(crate) use read::is_look_slot; @@ -74,6 +77,19 @@ const MAX_ZSTD_WINDOW: u64 = 64 << 20; /// Most Zstandard frames read (the app writes one). const MAX_FRAMES: usize = 4096; +#[cfg(test)] +thread_local! { + /// How many bytes [`Codec::decode`] has looked at on this thread to find whether another + /// Zstandard frame follows or only zero padding is left. + static PADDING_LOOKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`PADDING_LOOKS`] of this thread. +#[cfg(test)] +fn padding_looks() -> u64 { + PADDING_LOOKS.get() +} + const EPS_BEGIN: &[u8] = b"%AI9_PrivateDataBegin"; const EPS_END: &[u8] = b"%AI9_PrivateDataEnd"; @@ -171,7 +187,11 @@ impl Codec { // can itself end in a zero byte, so the padding is what is left after the last frame. let mut src = data; let mut frames = 0usize; - while src.iter().any(|b| *b != 0) { + while src.iter().any(|b| { + #[cfg(test)] + PADDING_LOOKS.set(PADDING_LOOKS.get().saturating_add(1)); + *b != 0 + }) { frames += 1; if frames > MAX_FRAMES { return Err(damaged("its Zstandard compression")); diff --git a/crates/eps/src/import/ai/brush.rs b/crates/eps/src/import/ai/brush.rs index 0bb41977f..47fdc34fe 100644 --- a/crates/eps/src/import/ai/brush.rs +++ b/crates/eps/src/import/ai/brush.rs @@ -31,14 +31,15 @@ //! after a space for Art and Pattern), more fields, and last 15 numbers. //! - Scatter: the size (2, 3), spacing (5, 6) and scatter (8, 9) as (low, high) factors of the //! art, the rotation in degrees (11, 12), how each varies (4, 7, 10, 13) and whether the -//! rotation follows the path (14). +//! rotation follows the path (14), and 1 Tints (1). //! - Art: 1 Tints (2). //! - Pattern: its scale as a factor (3). //! - A Bristle brush's parameters start with 8 numbers: two codes, then the stiffness, bristle //! thickness, density, paint opacity and bristle length as fractions and the size in //! millimeters. //! - The art is a `%AI8_BeginBrushPattern` section: its name as a string, then the art (read by -//! [`super::read`]). +//! [`super::read`]), usually in a group after its frame, an unpainted rectangle around it. A frame +//! at the art's bounds isn't kept ([`without_frame`]); a wider one is. //! //! What isn't read keeps its default: the Art brush's other options, the pattern brush's tiles //! (which slot is which tile isn't known) and the colorization of the other brushes, which is left @@ -327,7 +328,8 @@ pub(super) fn parse_tool_object(kind: Tool, name: &str, params: &str) -> Result< scatter, rotation, rotation_relative_to_path: *p14 == 1.0, - colorization: Colorization::None, + // The first of the 15 numbers: 1 is Tints. + colorization: if n.first() == Some(&1.0) { Colorization::Tints } else { Colorization::None }, modes: Some(modes), ..Scatter::default() }; @@ -471,3 +473,79 @@ pub(super) fn finish_brushes_within( pub(super) fn left_out_note(n: usize) -> String { format!("brushes VectorCraft can't read yet were left out of the Brushes panel ({n})") } + +/// Is `n` a frame: an unnamed, unpainted rectangle with nothing of its own (shown, unlocked, at full +/// opacity in Normal, with no effects, note, mask or other settings), a path of one subpath whose +/// anchors, without handles, are its four corners in order around it: closed, with the first again +/// at the end or not, or open with the first again at the end? +fn unpainted_rectangle(n: &Node) -> bool { + let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind else { return false }; + let ([sp], Some(b)) = (path.subpaths.as_slice(), n.geometric_bounds()) else { return false }; + let corner = |p: kurbo::Point| (p.x == b.x0 || p.x == b.x1) && (p.y == b.y0 || p.y == b.y1); + let mut corners: Vec = vec![]; + for a in &sp.anchors { + if !corners.contains(&a.p) { + corners.push(a.p); + } + } + let last_repeats = sp.anchors.len() == 5 && sp.anchors.first().map(|a| a.p) == sp.anchors.last().map(|a| a.p); + // Each corner to the next goes along a side, not across: one coordinate stays. + let sides = corners.len() == 4 + && (0..4).all(|i| match (corners.get(i), corners.get((i + 1) % 4)) { + (Some(a), Some(b)) => (a.x == b.x) != (a.y == b.y), + _ => false, + }); + n.name.is_none() + && *n.extras() == vectorcraft_doc::NodeExtras::default() + && n.visible + && !n.locked + && n.opacity >= 1.0 + && n.blend == vectorcraft_color::BlendMode::Normal + && !n.isolate + && n.knockout == vectorcraft_doc::Knockout::Neutral + && !n.knockout_shape + && n.appearance.effects.is_empty() + && n.appearance.items.iter().all(|i| i.paint().is_none()) + && ((sp.closed && sp.anchors.len() == 4) || last_repeats) + && sides + && b.width() > 0.0 + && b.height() > 0.0 + && sp.anchors.iter().all(|a| a.h_in == a.p && a.h_out == a.p && corner(a.p)) +} + +/// Brush art `art` without its frame: a group whose first object is an unpainted rectangle around +/// the rest, exactly (within 1/1000 pt), loses that rectangle, and a group without a look or state +/// of its own (hidden, locked, isolation, knockout, opacity, blend mode, name, appearance, or a mask, +/// note or other setting) that is left with one object is that object. A frame wider than the art +/// is kept. +pub(super) fn without_frame(art: Node) -> Node { + let NodeKind::Group { children, clip: false } = &art.kind else { return art }; + let Some((first, rest)) = children.split_first() else { return art }; + let around = rest.iter().filter_map(|n| n.geometric_bounds()).reduce(|a, b| a.union(b)); + let exact = match (around, first.geometric_bounds()) { + (Some(a), Some(f)) => [a.x0 - f.x0, a.y0 - f.y0, a.x1 - f.x1, a.y1 - f.y1].iter().all(|d| d.abs() <= 1e-3), + _ => false, + }; + if !exact || !unpainted_rectangle(first) { + return art; + } + let plain = art.opacity >= 1.0 + && art.visible + && !art.locked + && !art.isolate + && art.knockout == vectorcraft_doc::Knockout::Neutral + && !art.knockout_shape + && art.blend == vectorcraft_color::BlendMode::Normal + && art.name.is_none() + && *art.extras() == vectorcraft_doc::NodeExtras::default() + && art.appearance.effects.is_empty() + && art.appearance.items.is_empty(); + if let ([only], true) = (rest, plain) { + return Node::clone(only); + } + let mut out = art.clone(); + if let Some(c) = out.children_mut() { + c.remove(0); + } + out +} diff --git a/crates/eps/src/import/ai/envelope.rs b/crates/eps/src/import/ai/envelope.rs index d9f392215..bccaa050a 100644 --- a/crates/eps/src/import/ai/envelope.rs +++ b/crates/eps/src/import/ai/envelope.rs @@ -23,7 +23,7 @@ //! one-patch mesh. An envelope whose parts don't read, or that doesn't come out as drawn, keeps its //! drawn art. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use kurbo::{Affine, BezPath, Point, Rect, Vec2}; @@ -31,7 +31,7 @@ use vectorcraft_doc::live::{ EnvelopeKind, EnvelopeOptions, GradientMesh, H_DOWN, H_LEFT, H_RIGHT, H_UP, PreserveShape, default_fidelity, envelope_content_boxes, envelope_stroke_margin, }; -use vectorcraft_doc::{Document, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; +use vectorcraft_doc::{Appearance, Document, Effect, ImageBlob, LayerColor, Node, NodeId, NodeKind, PatternDef}; use vectorcraft_geom::PathData; use super::obj::Obj; @@ -41,10 +41,12 @@ use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE, compa pub(super) const PLUGIN: &str = "Adobe Deform Plugin"; // brand-ok: the plugin name the files use /// The note for an envelope that keeps its drawn art. pub(crate) const DRAWN: &str = "an envelope VectorCraft couldn't read as a live envelope comes in as its drawn art"; +/// The note for warped type that keeps its drawn art, before how many. +pub(crate) const WARPED_TYPE_DRAWN: &str = "warped text objects that came in as drawn art, not as editable type"; /// The most envelopes waiting for their drawn art at once. pub(super) const MAX_PENDING: usize = 64; -/// The most envelopes one import draws to check; more keep their drawn art. -pub(super) const MAX_CHECKED: usize = 256; +/// The most envelopes and warped text objects one import draws to check; more keep their drawn art. +pub(crate) const MAX_CHECKED: usize = 256; /// The most rows or columns of a mesh read (Make with Mesh's own limit). const MAX_GRID: u32 = 50; /// How far, in points over width and height together, a saved size may lie from the contents' @@ -343,14 +345,107 @@ fn differing(live: &Document, drawn: &Document, region: Rect, scale: f64) -> Opt Some((any, solid, inked)) } -/// The differences from their drawn art that the live envelopes of one import may add up to, as -/// shares of the page drawn as the page check draws it: half of what that check allows -/// ([`MAX_DIFFERENCE`], [`MAX_SOLID_DIFFERENCE`]), so live envelopes never make the file open from -/// its PDF part instead. +/// The differences from their drawn art that the live envelopes and warped type of one import may +/// add up to, as shares of the page drawn as the page check draws it: half of what that check allows +/// ([`MAX_DIFFERENCE`], [`MAX_SOLID_DIFFERENCE`]), so they never make the file open from its PDF part +/// instead. #[derive(Debug, Default)] -pub(super) struct Spent { - any: f64, - solid: f64, +pub(crate) struct Spent { + pub(super) any: f64, + pub(super) solid: f64, +} + +/// A text object read with a Warp. +#[derive(Debug)] +pub(super) struct WarpedLook { + /// The id of its slot. + pub(super) slot: NodeId, + /// The drawn art its style's look saved. + pub(super) look: Node, + /// That art's box, when the comparisons with the page leave it out ([`super::Structure::restyled`]). + pub(super) left_out: Option, +} + +/// The text objects read with a Warp, and what the import has left of the budget for checks +/// ([`drawn_alike`]). Type is laid out after the layers are read, so these wait for that +/// ([`Self::settle`]). +#[derive(Debug, Default)] +pub struct WarpedType { + pub(super) looks: Vec, + pub(super) checks: usize, + pub(super) spent: Spent, + pub(super) page: Option, +} + +impl WarpedType { + /// Replace each warped text object of `doc` that doesn't draw as its saved look (or that the + /// budget has no check left for) with that look: `made` gives the text object a slot became. A + /// frame in `threaded` stays type, since the frames of a thread share one story. A replaced + /// object's box goes back into the comparisons with the page (out of `restyled`), and only what + /// `doc` shows counts against the page's share of the budget. Returns how many were replaced. + pub(crate) fn settle( + self, + doc: &mut Document, + made: impl Fn(NodeId) -> Option, + threaded: &BTreeSet, + restyled: &mut Vec, + ) -> usize { + let Self { looks, mut checks, mut spent, page } = self; + let mut shown_ids = BTreeSet::new(); + doc.layers.iter().for_each(|l| collect_shown(l, &mut shown_ids)); + let mut replaced = 0; + for WarpedLook { slot, mut look, left_out } in looks { + let Some(id) = made(slot).filter(|id| !threaded.contains(id)) else { continue }; + let Some(text) = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)) && warps(&n.appearance)).cloned() else { continue }; + let mut alike = false; + if checks > 0 { + checks -= 1; + let layer = doc.alloc_id(); + let shared = Shared { patterns: &doc.patterns, images: &doc.images }; + let (mut shown, mut plain) = (text.clone(), look.clone()); + (shown.visible, shown.opacity) = (true, 1.0); + (plain.visible, plain.opacity) = (true, 1.0); + alike = warped_alike(&shown, &plain, layer, &shared, page.filter(|_| shown_ids.contains(&id)), &mut spent); + } + if alike { + continue; + } + (look.id, look.name, look.visible, look.locked) = (id, text.name.clone(), text.visible, text.locked); + if let Some(wrap) = text.extras().wrap { + look.extras_mut().wrap = Some(wrap); + } + if let Some(n) = doc.node_mut(id) { + *n = look; + replaced += 1; + if let Some(at) = left_out.and_then(|b| restyled.iter().position(|r| *r == b)) { + restyled.remove(at); + } + } + } + replaced + } +} + +#[cfg(test)] +impl WarpedType { + /// The text objects of `looks` (slot and saved look) with no check left. + pub(crate) fn unchecked(looks: Vec<(NodeId, Node)>) -> Self { + Self { looks: looks.into_iter().map(|(slot, look)| WarpedLook { slot, look, left_out: None }).collect(), ..Default::default() } + } +} + +/// Add to `out` the ids of `n` and what is in it, unless hidden. +fn collect_shown(n: &Node, out: &mut BTreeSet) { + if n.visible { + out.insert(n.id); + n.children().into_iter().flatten().for_each(|c| collect_shown(c, out)); + } +} + +/// Does `appearance` have a Warp that shows, on the object or on one of its fills or strokes? +pub(super) fn warps(appearance: &Appearance) -> bool { + let warp = |e: &Effect| e.visible && e.id.starts_with("warp."); + appearance.effects.iter().any(warp) || appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(warp)) } /// Do `live` and `drawn` draw alike: few of the pixels either inks differ, and fewer still where a @@ -358,7 +453,19 @@ pub(super) struct Spent { /// with the differences in `spent` the page check still takes the layers' art (it is added to /// `spent` when they do)? Both are drawn on `layer`, a layer id no other node has. pub(super) fn drawn_alike(live: &Node, drawn: &Node, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { - let (Some(a), Some(b)) = (live.visual_bounds(), drawn.visual_bounds()) else { return false }; + alike_within(live, drawn, live.visual_bounds(), layer, shared, page, spent) +} + +/// [`drawn_alike`] for type under a Warp, compared over all that its effects draw, which can reach +/// past its own box. +pub(super) fn warped_alike(live: &Node, drawn: &Node, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { + let reach = vectorcraft_render::effect_bounds(&alone(live, layer, shared).symbol_boxes(), live); + alike_within(live, drawn, vectorcraft_geom::union_opt(live.visual_bounds(), reach), layer, shared, page, spent) +} + +/// [`drawn_alike`], with `live` measured as `reach`. +fn alike_within(live: &Node, drawn: &Node, reach: Option, layer: NodeId, shared: &Shared<'_>, page: Option, spent: &mut Spent) -> bool { + let (Some(a), Some(b)) = (reach, drawn.visual_bounds()) else { return false }; let region = a.union(b).inflate(2.0, 2.0); if !(region.is_finite() && region.width() > 0.0 && region.height() > 0.0) || region.width().max(region.height()) > 1e6 { return false; diff --git a/crates/eps/src/import/ai/legacy_text.rs b/crates/eps/src/import/ai/legacy_text.rs index 1fb369053..a1b24662e 100644 --- a/crates/eps/src/import/ai/legacy_text.rs +++ b/crates/eps/src/import/ai/legacy_text.rs @@ -3,17 +3,21 @@ //! type runs along, `TP`), the modal character and paragraph attributes //! (`Tr Tf Ts Tz Tt TA Tl Ta Ti`, and §15.4.7 and §22's letter and word spacing `TC TW`, hanging //! punctuation `Tq`, hyphenation `Th` and tab stops `Xb Xe XB`, and §15.3.4's writing direction `TV`, -//! Roman character rotation `Tv` and Kumi blocks `Ty TY`), the paint style, and the characters (`Tx`, `Tj`, -//! overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the +//! Roman character rotation `Tv`, Kumi blocks `Ty TY` and binding punctuation `Tg`), the paint +//! style, and the characters (`Tx`, `Tj`, overflow `TX`; `\r` ends a paragraph). Type set in rendering modes 4 to 7 is a mask (§5.7): the //! text object clips the objects of its clipping group. Final-form operators (the //! computed positions, §15.1) are skipped: the type is laid out again. Strings are decoded by the //! encoding pairs of the current font's `TZ`, then by the file's `TE` encoding unless that `TZ`'s //! `useDefault` is 0, else as Windows-1252; a re-encoded font `/_name` is the font its `%AI3_BeginEncoding` or `TZ` names -//! (else `name`). +//! (else `name`). Strings in a composite font named `base-CMap` (CJK type) whose `TZ` gives no +//! encoding pairs are decoded by that predefined CMap instead, and the font is `base`. +use std::sync::Arc; + +use craft_pdf::interpret::hayro_cmap::{BfString, CMap, CMapName, load_embedded}; use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen}; use vectorcraft_color::Paint; -use vectorcraft_doc::{CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; +use vectorcraft_doc::{Burasagari, CharStyle, Justify, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject, TextRun}; use vectorcraft_geom::AnchorKind; use super::super::obj::V; @@ -206,8 +210,14 @@ pub(super) struct TypeState { /// Re-encoded fonts' own encodings (`TZ`), by the new font's name: what its encoding pairs /// change, by code (None where they say nothing), and whether `TE`'s encoding applies to it. font_encodings: Vec<(String, Vec>, bool)>, + /// The new fonts' names whose `TZ` gives encoding pairs, which no composite font has. + paired_fonts: Vec, /// The font `Tf` set last, as the file names it. font: Option, + /// How strings in that font decode, when it is a composite font ([`composite_font`]). + cmap: Option>, + /// The predefined CMaps read so far, by name (`None` when one couldn't be read). + cmaps: Vec<(String, Option>)>, /// Between `Xb` and `XB`: the tab stops of the tab definition read so far. tabs: Option>, chars: usize, @@ -225,7 +235,10 @@ impl Default for TypeState { encoding: vec![None; 256], fonts: vec![], font_encodings: vec![], + paired_fonts: vec![], font: None, + cmap: None, + cmaps: vec![], tabs: None, chars: 0, } @@ -251,6 +264,9 @@ impl TypeState { /// A string's bytes, in the current font. fn decode(&self, bytes: &[u8]) -> String { + if let Some(cmap) = &self.cmap { + return cmap.decode(bytes); + } let own = self.font.as_ref().and_then(|f| self.font_encodings.iter().find(|(n, _, _)| n == f)); let (own, te) = own.map_or((None, true), |(_, e, te)| (Some(e.as_slice()), *te)); bytes @@ -265,6 +281,260 @@ impl TypeState { }) .collect() } + + /// How strings in a font with predefined CMap `cmap` decode; `None` when the CMap can't be read. + fn cmap_codes(&mut self, cmap: &str) -> Option> { + if let Some(form) = unicode_cmap(cmap) { + return Some(Arc::new(CMapCodes::Unicode(form))); + } + if let Some((_, codes)) = self.cmaps.iter().find(|(n, _)| n == cmap) { + return codes.clone(); + } + let codes = cid_codes(cmap).map(Arc::new); + self.cmaps.push((cmap.to_string(), codes.clone())); + codes + } +} + +/// How a composite font's strings decode. +#[derive(Debug)] +enum CMapCodes { + /// By a predefined CMap's codes to CIDs, then by its character collection's CID → Unicode CMap; + /// with `high_bit`, each byte 0x21 to 0x7E gets its high bit set first. `space` is the CMap's + /// code space ([`code_space`]). + Cids { codes: Box, unicode: Box, high_bit: bool, space: Option }, + /// A predefined Unicode CMap: the codes are the characters. + Unicode(Unicode), +} + +/// A CMap's code space: ranges of codes, each given by its lowest and highest code, both with the +/// code's length in bytes. +type CodeSpace = &'static [(&'static [u8], &'static [u8])]; + +/// What control byte `b` is in a composite font's string: `\r` a paragraph break, `\t` a tab. +fn control(b: u8) -> Option { + match b { + b'\r' => Some('\n'), + b'\t' => Some('\t'), + _ => None, + } +} + +impl CMapCodes { + /// The characters of string `bytes`, leaving out codes the CMaps don't map; a `\r` is a + /// paragraph break and a `\t` a tab (also as a byte left over after the last whole code of a + /// UTF-16 or UTF-32 string). + fn decode(&self, bytes: &[u8]) -> String { + let (codes, unicode, high_bit, space) = match self { + Self::Unicode(form) => { + let unit = match form { + Unicode::Utf16 => 2, + Unicode::Utf8 => 1, + Unicode::Utf32 => 4, + }; + let (whole, rest) = bytes.split_at_checked(bytes.len() - bytes.len() % unit).unwrap_or((bytes, &[])); + let mut text = unicode_text(whole, *form).replace('\r', "\n"); + text.extend(rest.last().copied().and_then(control)); + return text; + } + Self::Cids { codes, unicode, high_bit, space } => (codes, unicode, *high_bit, space), + }; + let euc: Vec; + let bytes = if high_bit { + euc = bytes.iter().map(|&b| if (0x21..0x7f).contains(&b) { b | 0x80 } else { b }).collect(); + euc.as_slice() + } else { + bytes + }; + let mut out = String::new(); + let mut at = 0; + while let Some(&first) = bytes.get(at) { + // VectorCraft reads a 0x0D byte as a paragraph break and a 0x09 byte as a tab in every + // CMap, as in other fonts' strings, including the row-and-cell CMaps, whose code space + // has no one-byte codes. + if let Some(control) = control(first) { + out.push(control); + at += 1; + continue; + } + let rest = bytes.get(at..).unwrap_or_default(); + let code = |len: u8| rest.get(..usize::from(len)).map(|b| b.iter().fold(0u32, |c, &b| c << 8 | u32::from(b))); + let found = match space { + // The code is as long as the code space range it falls in; a byte that starts no + // code in the code space is skipped. + Some(space) => { + let Some(len) = code_len(space, rest) else { + at += 1; + continue; + }; + code(len).map(|c| (len, codes.lookup_cid_code(c, len))) + } + None => (1..=4u8).find_map(|len| Some((len, Some(codes.lookup_cid_code(code(len)?, len)?)))), + }; + let Some((len, cid)) = found else { + at += 1; + continue; + }; + at += usize::from(len); + // A code the CMap leaves out is dropped. + let Some(cid) = cid else { continue }; + match unicode.lookup_bf_string(cid) { + // A one-byte Roman code whose CID the table gives as a combining mark alone (0x7E in + // the Mac Shift-JIS CMaps, U+0303): the ASCII character, as Adobe's cid2code.txt lists first. + Some(BfString::Char('\u{300}'..='\u{36f}')) if len == 1 && (0x20..0x7f).contains(&first) => out.push(char::from(first)), + Some(BfString::Char(c)) if c != char::REPLACEMENT_CHARACTER => out.push(c), + Some(BfString::String(s)) => out.extend(s.chars().filter(|c| *c != char::REPLACEMENT_CHARACTER)), + _ => {} + } + } + out + } +} + +/// How a predefined Unicode CMap's codes encode their characters. +#[derive(Clone, Copy, PartialEq, Debug)] +enum Unicode { + /// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first. + Utf16, + Utf8, + /// Four bytes, the high byte first. + Utf32, +} + +/// The encoding of predefined Unicode CMap `name`: `Uni` and a character collection, then `-UCS2`, +/// `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms), then `-H` or `-V` +/// (`UniJIS-UCS2-H`, `UniGB-UTF16-V`: ). +fn unicode_cmap(name: &str) -> Option { + let rest = name.strip_suffix("-H").or_else(|| name.strip_suffix("-V"))?; + let rest = rest.strip_suffix("-HW").unwrap_or(rest); + let (collection, form) = rest.rsplit_once('-')?; + let collections = ["UniJIS", "UniJISPro", "UniJIS2004", "UniJISX0213", "UniJISX02132004", "UniHojo", "UniGB", "UniCNS", "UniKS", "UniAKR"]; + if !collections.contains(&collection) { + return None; + } + match form { + "UCS2" | "UTF16" => Some(Unicode::Utf16), + "UTF8" => Some(Unicode::Utf8), + "UTF32" => Some(Unicode::Utf32), + _ => None, + } +} + +/// The characters string `s` encodes in Unicode form `form` (invalid codes left out). +fn unicode_text(s: &[u8], form: Unicode) -> String { + match form { + Unicode::Utf16 => { + let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b)); + char::decode_utf16(units).filter_map(Result::ok).collect() + } + Unicode::Utf8 => s.utf8_chunks().map(|chunk| chunk.valid()).collect(), + Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(), + } +} + +/// Predefined CMap `name` among those VectorCraft carries, leaving out the Identity CMaps, whose +/// codes are CIDs of no known character collection. +fn predefined(name: &str) -> Option> { + match CMapName::from_bytes(name.as_bytes()) { + CMapName::Custom(_) | CMapName::IdentityH | CMapName::IdentityV => None, + name => Some(name), + } +} + +/// Adobe's predefined CMaps that VectorCraft doesn't carry, by name without `-H` or `-V` +/// (): the nearest carried CMap with the same +/// code space, and whether each byte of a row-and-cell code gets its high bit set first (the EUC +/// form of the same code; `GB-H`, `CNS1-H` and `KSC-H` have no one-byte codes, their code space +/// is `<2121>` to `<7E7E>`); `None` for those no carried CMap reads. +fn stand_in(stem: &str) -> Option> { + Some(match stem { + "RKSJ" | "78-RKSJ" | "78ms-RKSJ" => Some(("90ms-RKSJ-H", false)), + "78-EUC" => Some(("EUC-H", false)), + "78" | "Add" | "Ext" | "NWP" => Some(("H", false)), + "GB" => Some(("GB-EUC-H", true)), + "CNS1" => Some(("CNS-EUC-H", true)), + "KSC" => Some(("KSC-EUC-H", true)), + "B5" => Some(("ETen-B5-H", false)), + "ETHK-B5" | "HKdla-B5" | "HKdlb-B5" | "HKgccs-B5" | "HKm314-B5" | "HKm471-B5" => Some(("HKscs-B5-H", false)), + "GBT" | "GBT-EUC" | "GBTpc-EUC" | "CNS2" | "KSC-Johab" => None, + _ => return None, + }) +} + +/// Predefined CMap `cmap`'s codes to CIDs, and its character collection's CID → Unicode CMap. A +/// vertical CMap's codes are read with its horizontal CMap when VectorCraft carries that: the two +/// differ only in the CIDs of vertical glyph forms. A CMap VectorCraft doesn't carry is read with +/// its [`stand_in`]. +fn cid_codes(cmap: &str) -> Option { + let read = |name: &str| CMap::parse(load_embedded(predefined(name)?)?, load_embedded); + let horizontal = cmap.strip_suffix('V').map(|stem| format!("{stem}H")); + let stem = cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")); + let (codes, high_bit) = horizontal.iter().map(String::as_str).chain([cmap]).find_map(|n| Some((read(n)?, false))).or_else(|| { + let (name, high_bit) = stand_in(stem?).flatten()?; + Some((read(name)?, high_bit)) + })?; + let table = codes.metadata().character_collection.as_ref()?.family.ucs2_cmap()?; + let unicode = CMap::parse(load_embedded(table)?, load_embedded)?; + // A row-and-cell code read in its EUC form falls in the EUC CMap's code space. + let space = if high_bit { stand_in(stem?).flatten().and_then(|(name, _)| code_space(name)) } else { code_space(cmap) }; + Some(CMapCodes::Cids { codes: Box::new(codes), unicode: Box::new(unicode), high_bit, space }) +} + +/// The code space of predefined CMap `cmap`, as Adobe's CMap files give it +/// (); a CMap's vertical form has its +/// horizontal form's. `None` for a CMap not listed here. +fn code_space(cmap: &str) -> Option { + const RKSJ: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const RKSJ_MAC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa0", b"\xdf"), (b"\xfd", b"\xff"), (b"\x81\x40", b"\x9f\xfc"), (b"\xe0\x40", b"\xfc\xfc")]; + const EUC_JP: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\xa0", b"\x8e\xdf"), (b"\xa1\xa1", b"\xfe\xfe")]; + const ROW_CELL: CodeSpace = &[(b"\x21\x21", b"\x7e\x7e")]; + const EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe")]; + const GB_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\xa1", b"\xfc\xfe")]; + const GBK: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x40", b"\xfe\xfe")]; + const GBK2K: CodeSpace = &[(b"\x00", b"\x7f"), (b"\x81\x40", b"\xfe\xfe"), (b"\x81\x30\x81\x30", b"\xfe\x39\xfe\x39")]; + const B5_PC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xfd", b"\xff"), (b"\xa1\x40", b"\xfc\xfe")]; + const B5: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\x40", b"\xfe\xfe")]; + const HKSCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x87\x40", b"\xfe\xfe")]; + const HKDLB: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8e\x40", b"\xfe\xfe")]; + const HKGCCS: CodeSpace = &[(b"\x00", b"\x80"), (b"\x8a\x40", b"\xfe\xfe")]; + const CNS_EUC: CodeSpace = &[(b"\x00", b"\x80"), (b"\xa1\xa1", b"\xfe\xfe"), (b"\x8e\xa1\xa1\xa1", b"\x8e\xa3\xfe\xfe")]; + const KSC_PC: CodeSpace = &[(b"\x00", b"\x84"), (b"\xfe", b"\xff"), (b"\xa1\x41", b"\xfd\xfe")]; + const UHC: CodeSpace = &[(b"\x00", b"\x80"), (b"\x81\x41", b"\xfe\xfe")]; + let stem = match cmap { + "H" | "V" => "", + _ => cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V"))?, + }; + let stem = stem.strip_suffix("-HW").unwrap_or(stem); + Some(match stem { + "83pv-RKSJ" | "90pv-RKSJ" => RKSJ_MAC, + _ if stem.ends_with("RKSJ") => RKSJ, + "EUC" | "78-EUC" => EUC_JP, + "" | "78" | "Add" | "Ext" | "NWP" => ROW_CELL, + "GB-EUC" | "GBT-EUC" | "KSC-EUC" => EUC, + "GBpc-EUC" | "GBTpc-EUC" => GB_PC, + "GBK-EUC" | "GBKp-EUC" => GBK, + "GBK2K" => GBK2K, + "B5pc" => B5_PC, + "B5" | "ETen-B5" | "ETenms-B5" | "HKdla-B5" | "HKm314-B5" | "HKm471-B5" => B5, + "HKscs-B5" | "ETHK-B5" => HKSCS, + "HKdlb-B5" => HKDLB, + "HKgccs-B5" => HKGCCS, + "CNS-EUC" => CNS_EUC, + "KSCpc-EUC" => KSC_PC, + "KSCms-UHC" => UHC, + _ => return None, + }) +} + +/// The length of the code string `bytes` starts with: the length of the code space range whose +/// lowest and highest codes hold each of its bytes between them. `None` when no range does. +fn code_len(space: CodeSpace, bytes: &[u8]) -> Option { + space + .iter() + .filter(|(lo, hi)| bytes.get(..lo.len()).is_some_and(|b| b.iter().zip(lo.iter().zip(hi.iter())).all(|(b, (lo, hi))| (lo..=hi).contains(&b)))) + .map(|(lo, _)| lo.len()) + .min() + .and_then(|len| u8::try_from(len).ok()) } /// Encoding pairs (§3.3.1): codes, each followed by the glyph names from it on, into `table`. @@ -286,6 +556,36 @@ fn encoding_pairs(table: &mut [Option], vals: &[V]) { } } +/// A composite font's name `base-CMap`: the base font's name and its CMap's name, when that is a +/// predefined Unicode CMap ([`unicode_cmap`]), one of the predefined CMaps VectorCraft carries +/// ([`predefined`]) or one of the others ([`stand_in`]), in its horizontal (`-H`) or vertical +/// (`-V`) form. A name ending in an Identity CMap is not read as one, nor is one that ends in an +/// unknown CMap name with an encoding part (`UniFoo-UTF8-H`), which would otherwise end in `H`. +fn composite_font(name: &str) -> Option<(&str, &str)> { + if !(name.ends_with("-H") || name.ends_with("-V")) { + return None; + } + // The longest CMap name first: `83pv-RKSJ-H` before `H`. + // No predefined CMap name is longer than 32 bytes. + for (at, _) in name.match_indices('-').filter(|(at, _)| name.len() - at <= 33) { + let (Some(base), Some(cmap)) = (name.get(..at).filter(|b| !b.is_empty()), name.get(at + 1..)) else { continue }; + if matches!(cmap, "Identity-H" | "Identity-V") { + return None; + } + let encoding_part = + base.rsplit('-').next().is_some_and(|part| ["UCS2", "UTF8", "UTF16", "UTF32", "HW", "EUC", "RKSJ", "B5", "UHC", "Johab"].contains(&part)); + if matches!(cmap, "H" | "V") && encoding_part { + return None; + } + let vertical_of_predefined = || cmap.strip_suffix('V').is_some_and(|stem| predefined(&format!("{stem}H")).is_some()); + let other = || cmap.strip_suffix("-H").or_else(|| cmap.strip_suffix("-V")).is_some_and(|stem| stand_in(stem).is_some()); + if unicode_cmap(cmap).is_some() || predefined(cmap).is_some() || vertical_of_predefined() || other() { + return Some((base, cmap)); + } + } + None +} + /// An operator's `N` operands, the last `N` values before it, when each is a finite number. fn nums(vals: &[V]) -> Option<[f64; N]> { let operands = vals.get(vals.len().checked_sub(N)?..)?; @@ -341,6 +641,9 @@ impl Reader<'_> { let pairs = pairs.iter().rposition(|v| matches!(v, V::Mark)).map_or(pairs, |m| pairs.get(m + 1..).unwrap_or_default()); let mut table = vec![None; 256]; encoding_pairs(&mut table, pairs); + if pairs.iter().any(|v| matches!(v, V::Name(_))) { + st.paired_fonts.push(new.clone()); + } st.font_encodings.push((new.clone(), table, te)); } @@ -469,9 +772,17 @@ impl Reader<'_> { let Some(at) = vals.iter().position(|v| matches!(v, V::Name(_))) else { return }; if let Some(V::Name(name)) = vals.get(at) { st.font = Some(name.clone()); - let name = + let full = st.fonts.iter().find(|(new, _)| new == name).map_or_else(|| name.trim_start_matches('_').to_string(), |(_, old)| old.clone()); - let (family, style) = super::super::super::text::family_style(&name); + // A font whose `TZ` gives encoding pairs is re-encoded, which a composite font + // can't be; a `TZ` without pairs only names the font. + let reencoded = st.paired_fonts.contains(name); + let (name, cmap) = match composite_font(&full).filter(|_| !reencoded) { + Some((base, cmap)) => (base, st.cmap_codes(cmap)), + None => (full.as_str(), None), + }; + st.cmap = cmap; + let (family, style) = super::super::super::text::family_style(name); st.style.font_family = family; st.style.font_style = style; st.style.font_version = None; @@ -559,6 +870,19 @@ impl Reader<'_> { st.para.roman_hanging = on; } } + // `bindrepeat Tg` (§15.3.4): 1 and 3 turn binding punctuation on, and 0 and 2 turn it + // off. VectorCraft's own mapping opens it on as Burasagari's Force setting and off as no + // Burasagari. The repeat character (2 and 3) is not kept. + "Tg" => { + let burasagari = match n0 { + Some(1.0 | 3.0) => Some(Burasagari::Forced), + Some(0.0 | 2.0) => Some(Burasagari::None), + _ => None, + }; + if let Some(b) = burasagari { + st.para.burasagari = b; + } + } // `hyphenate limitHyphLines minLeadHyphen minTailHyphen maxHyphLines Th`, kept to the // ranges `text.setFormat` takes. "Th" => { diff --git a/crates/eps/src/import/ai/read.rs b/crates/eps/src/import/ai/read.rs index 4dbf31a24..60ccd43ca 100644 --- a/crates/eps/src/import/ai/read.rs +++ b/crates/eps/src/import/ai/read.rs @@ -138,6 +138,7 @@ pub use document::{ BindingRef, Brushes, PaletteSwatches, VariableRead, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, variables, }; pub(crate) use document::{slot_frame, slot_of}; +pub(crate) use ops::read_art_style; pub use types::IMAGE_TOO_LARGE; use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, Raster, SymbolHead}; #[cfg(test)] @@ -147,6 +148,10 @@ pub(super) use types::{blend_mode, blend_name}; #[path = "legacy_text.rs"] mod legacy_text; +// The `.ai` writer defines symbols as this reader keeps them. +pub(crate) use directives::symbol_art_member; +pub(crate) use types::MAX_SYMBOLS; + /// What the editing data holds: the document of its art, and where its art space is. #[derive(Debug)] pub struct Structure { @@ -183,6 +188,8 @@ pub struct Structure { /// The live envelopes read from the editing data, by id, with the drawn art each took the place /// of: the importer puts that art back when the layers with the envelopes don't draw as the page. pub envelopes_drawn: Vec<(NodeId, Node)>, + /// The warped text objects, checked against their drawn looks once their type is laid out. + pub warped_type: super::envelope::WarpedType, /// The document's graphic styles are those of the file's style list, in place of the defaults. pub styles_read: bool, /// The file's `%AI24_LargeCanvasScale` (1 when it declares none), applied or not. @@ -495,10 +502,13 @@ struct Reader<'a> { envelope_art: Option, /// Envelopes waiting for their drawn art, innermost last. envelopes: Vec, - /// How many more envelopes this import may draw to check ([`super::envelope::drawn_alike`]). + /// How many more envelopes and warped text objects this import may draw to check + /// ([`super::envelope::drawn_alike`]). envelope_checks: usize, /// How far the live envelopes read so far draw from their drawn art ([`super::envelope::Spent`]). envelope_spent: super::envelope::Spent, + /// The text objects read with a Warp ([`super::envelope::WarpedType`]). + warped_type: Vec, /// The live envelopes made so far and the drawn art each took the place of /// ([`Structure::envelopes_drawn`]). envelopes_drawn: Vec<(NodeId, Node)>, @@ -629,6 +639,9 @@ impl<'a> Reader<'a> { } fn new(data: &'a [u8]) -> Self { + // An envelope's saved margins and its check against its drawn art measure type by its + // glyphs, as the app maps it. + vectorcraft_text::register_envelope_ink(); let mut doc = Document::new(1.0, 1.0); doc.layers.clear(); Self { @@ -700,6 +713,7 @@ impl<'a> Reader<'a> { envelopes: Vec::new(), envelope_checks: super::envelope::MAX_CHECKED, envelope_spent: super::envelope::Spent::default(), + warped_type: Vec::new(), envelopes_drawn: Vec::new(), clip_fills: HashSet::new(), looks: HashSet::new(), diff --git a/crates/eps/src/import/ai/read/directives.rs b/crates/eps/src/import/ai/read/directives.rs index 307105f95..45f60916b 100644 --- a/crates/eps/src/import/ai/read/directives.rs +++ b/crates/eps/src/import/ai/read/directives.rs @@ -761,7 +761,8 @@ impl<'a> Reader<'a> { let node = match art.len() { 1 => art.pop().map(Arc::unwrap_or_clone), _ => Some(Node::group(self.doc.alloc_id(), art)), - }; + } + .map(brush::without_frame); let Some(node) = node else { return }; self.brush_art.insert(name.clone(), node); let mut colors = BrushArtColors { spots: sub.spots, stop_spots: sub.stop_spots, cmyk: sub.cmyk_colors, rgb: sub.rgb_colors }; diff --git a/crates/eps/src/import/ai/read/document.rs b/crates/eps/src/import/ai/read/document.rs index 084f4d4b6..1cbadc044 100644 --- a/crates/eps/src/import/ai/read/document.rs +++ b/crates/eps/src/import/ai/read/document.rs @@ -1032,6 +1032,8 @@ impl<'a> Reader<'a> { } let to_doc = self.ensure_space(); let artboard = self.first_board(); + // Before the artboards are taken below: the page is the first artboard's. + let page = self.page_in_doc(); self.check_symbols(); self.order_symbols(); drop_commented_copies(&mut self.doc.layers, &self.hidden_ids); @@ -1139,6 +1141,9 @@ impl<'a> Reader<'a> { for a in brushes.iter_mut().flat_map(|(lib, _)| lib.iter_mut().flat_map(|b| b.art_mut())) { vectorcraft_doc::swatches::map_node_solid_paints(a, &mut follow); } + for w in &mut self.warped_type { + vectorcraft_doc::swatches::map_node_solid_paints(&mut w.look, &mut follow); + } } let mut warnings = self.warnings; if let Some((lib, unread)) = brushes { @@ -1179,6 +1184,12 @@ impl<'a> Reader<'a> { placeholders: self.placeholders, placed_elsewhere: self.placed_elsewhere, envelopes_drawn: self.envelopes_drawn, + warped_type: super::super::envelope::WarpedType { + looks: self.warped_type, + checks: self.envelope_checks, + spent: self.envelope_spent, + page, + }, styles_read, canvas_scale: self.canvas_scale, xml_ids: self.xml_ids, diff --git a/crates/eps/src/import/ai/read/ops.rs b/crates/eps/src/import/ai/read/ops.rs index b8c6daf6c..3d95c9485 100644 --- a/crates/eps/src/import/ai/read/ops.rs +++ b/crates/eps/src/import/ai/read/ops.rs @@ -360,13 +360,13 @@ impl<'a> Reader<'a> { "TO" => self.end_text(hidden)?, // `(text) justified TS`, special characters (§22.6): the reader adds its characters like `Tx`. "Tx" | "Tj" | "TX" | "TS" => self.text_body(w, &vals), - "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Xb" | "Xe" - | "XB" => { + "Tr" | "Tf" | "Ts" | "Tz" | "Tt" | "TA" | "Tl" | "Ta" | "Ti" | "TC" | "TW" | "Tq" | "Th" | "TV" | "Tv" | "Ty" | "TY" | "Tg" | "Xb" + | "Xe" | "XB" => { self.text_attr(w, &vals); } - // Attributes the reader doesn't keep (kerning pairs, line breaking, binding punctuation - // and Japanese layout rules) and final-form positions (§15.3). - "Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tg" | "Tu" | "Xu" => {} + // Attributes the reader doesn't keep (kerning pairs, line breaking and Japanese layout + // rules) and final-form positions (§15.3). + "Tk" | "TK" | "Tc" | "Tw" | "Tm" | "Td" | "T*" | "TR" | "T+" | "T-" | "TG" | "Tu" | "Xu" => {} // `TM` (pop text matrix) and `Te` (end render), listed in §22.6, keep nothing either; the // reader takes `Te` with any number of operands. "TM" | "Te" => {} @@ -706,6 +706,7 @@ impl<'a> Reader<'a> { self.looks.insert(look.id); } // The type that a text object's look draws is the object's own, whatever comes of its style. + let mut look_drew_type = false; if after && let Some(f) = self.frames.last_mut() && let [.., look, object] = f.children.as_mut_slice() @@ -715,7 +716,7 @@ impl<'a> Reader<'a> { && self.hidden_ids.contains(&look.id) == hidden && matches!(look.kind, NodeKind::Group { clip: false, .. }) { - strip_look_type(look); + look_drew_type = strip_look_type(look); } if code != 1.0 || style.is_empty() || !after { return; @@ -761,9 +762,25 @@ impl<'a> Reader<'a> { } let Some(mut object) = f.children.pop() else { return }; let Some(look) = f.children.pop() else { return }; - if let Some(b) = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible) { + let left_out = look.visual_bounds().filter(|_| !hidden && object.visible && look.visible); + if let Some(b) = left_out { self.restyled.push(b); } + // Warped type is checked against its look once it is laid out. The look stands in for + // the type only when it holds all that the object draws: not when it draws nothing or + // draws type of its own. + if slot_of(object.name.as_deref()).is_some() && crate::import::ai::envelope::warps(&appearance) && self.warped_type.len() < MAX_NODES { + let mut saved = look.clone(); + // When the object itself is on `%_` lines, every child of its look is too, so the + // look's own copies can't be told apart from the rest. + if !hidden { + strip(&mut saved, &self.hidden_ids); + } + if !look_drew_type && !holds_type(&saved) && saved.visual_bounds().is_some() && draws_art(&saved, 0) { + take_attributes(&mut saved, &object); + self.warped_type.push(crate::import::ai::envelope::WarpedLook { slot: object.id, look: saved, left_out }); + } + } object.appearance = appearance.into(); // The object's transparency is written on its look, after the look's group ends. (object.opacity, object.blend, object.isolate, object.knockout, object.knockout_shape) = @@ -814,29 +831,47 @@ impl<'a> Reader<'a> { look.name = object.name.clone().or(look.name.take()); look.visible = object.visible; look.locked = object.locked; - // The object's note, Show Center, URL and data (object tags) stay with the look that stands - // for it, in place of the look's own where the object has them. - if let Some(a) = object.extras().attrs.as_deref().cloned() { - look.edit_attrs(|l| { - if a.show_center.is_some() { - l.show_center = a.show_center; - } - if !a.url.is_empty() { - l.url = a.url; - } - if !a.note.is_empty() { - l.note = a.note; - } - let room = MAX_TAGS.saturating_sub(l.data.len()); - l.data.extend(a.data.into_iter().take(room)); - }); - } + take_attributes(look, &object); if !hidden { self.drawn_looks += 1; } } } +/// Does `n` or anything shown in it (to [`MAX_DEPTH`]) paint? Any object other than a path, a +/// compound path or a group counts: it draws more than its fills and strokes. +fn draws_art(n: &Node, depth: usize) -> bool { + depth > MAX_DEPTH + || !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Group { .. }) + || n.appearance.items.iter().any(|i| i.visible() && !i.paint().is_none()) + || n.children().into_iter().flatten().any(|c| c.visible && draws_art(c, depth + 1)) +} + +/// Is `n` or anything in it a text slot? +fn holds_type(n: &Node) -> bool { + slot_of(n.name.as_deref()).is_some() || n.children().into_iter().flatten().any(|c| holds_type(c)) +} + +/// The note, Show Center, URL and data (object tags) of `object` stay with the `look` that stands +/// for it, in place of the look's own where the object has them. +fn take_attributes(look: &mut Node, object: &Node) { + if let Some(a) = object.extras().attrs.as_deref().cloned() { + look.edit_attrs(|l| { + if a.show_center.is_some() { + l.show_center = a.show_center; + } + if !a.url.is_empty() { + l.url = a.url; + } + if !a.note.is_empty() { + l.note = a.note; + } + let room = MAX_TAGS.saturating_sub(l.data.len()); + l.data.extend(a.data.into_iter().take(room)); + }); + } +} + /// Whether `look`'s paths don't all paint the same. pub(super) fn paints_apart(look: &Node) -> bool { fn leaves<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { @@ -1121,6 +1156,37 @@ pub(super) fn appearance_from_art_style(st: &crate::import::ai::style::ArtStyle) Some(Appearance { items, effects: st.effects.clone(), contents_index: st.contents_index }) } +/// What the reader makes of art style `name` in `section` (the text between `%AI9_BeginArtStyles` +/// and its end), for writers that check a style before they write it. +#[derive(Clone, Debug)] +pub(crate) struct StyleRead { + /// The style's appearance: a gradient fill is the object's own gradient. + pub appearance: Appearance, + /// An object other than type is read with it ([`crate::import::ai::style::ArtStyle::simple`]). + pub simple: bool, + /// It has Effect › Pathfinder effects, which only a group with no fills or strokes is read with. + pub pathfinder: bool, +} + +/// Art style `name` of `section` as the reader reads it ([`StyleRead`]): [`appearance_from_art_style`], +/// with a style's one gradient fill as `own_gradient`, the object's own gradient, as [`styled`] reads +/// it on objects other than type. `None` when the section doesn't define it, the reader doesn't read +/// it, or it paints with a pattern, a brush or a gradient that isn't the object's own. +pub(crate) fn read_art_style(section: &[u8], name: &str, own_gradient: Option<&Paint>) -> Option { + use crate::import::ai::style::StyleItem; + let mut st = crate::import::ai::style::art_styles(section).remove(name)?; + if st.items.iter().filter(|i| matches!(i, StyleItem::Gradient { .. })).count() == 1 { + let own = own_gradient?; + for item in &mut st.items { + if matches!(item, StyleItem::Gradient { .. }) { + *item = StyleItem::Fill(own.clone()); + } + } + } + let appearance = appearance_from_art_style(&st)?; + Some(StyleRead { appearance, simple: st.simple, pathfinder: st.pathfinder }) +} + /// A style's stroke as an appearance item (`None` for a fill). fn stroke_layer(item: &crate::import::ai::style::StyleItem) -> Option { let crate::import::ai::style::StyleItem::Stroke { paint, width, cap, join, miter, dash, profile, brush } = item else { return None }; @@ -1138,14 +1204,19 @@ fn with_look(layer: &mut AppearanceItem, look: &crate::import::ai::style::ItemLo } } -/// Remove from `n` the text slots of the type its look draws ([`super::is_look_slot`]), in its groups too. -fn strip_look_type(n: &mut Node) { +/// Remove from `n` the text slots of the type its look draws ([`super::is_look_slot`]), in its groups +/// too. Returns whether there were any. +fn strip_look_type(n: &mut Node) -> bool { + let mut any = false; if let Some(children) = n.children_mut() { + let before = children.len(); children.retain(|c| !super::is_look_slot(c.name.as_deref())); + any = children.len() != before; for c in children.iter_mut().filter(|c| c.children().is_some_and(|k| !k.is_empty())) { - strip_look_type(Arc::make_mut(c)); + any |= strip_look_type(Arc::make_mut(c)); } } + any } /// Remove from `n` the children read from `%_` lines (`hidden`: the object itself, not its look). diff --git a/crates/eps/src/import/ai/read/types.rs b/crates/eps/src/import/ai/read/types.rs index d47805e97..8b9607d18 100644 --- a/crates/eps/src/import/ai/read/types.rs +++ b/crates/eps/src/import/ai/read/types.rs @@ -41,7 +41,7 @@ pub(crate) const FLIP: Affine = Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, 0.0]); pub(super) const MAX_PATTERNS: usize = 4096; /// Most symbol definitions read (instances of others come in as the page draws them). -pub(super) const MAX_SYMBOLS: usize = 4096; +pub(crate) const MAX_SYMBOLS: usize = 4096; /// Most names a symbol list orders. pub(super) const MAX_LISTED: usize = 4 * MAX_SYMBOLS; diff --git a/crates/eps/src/import/ai/style/tests.rs b/crates/eps/src/import/ai/style/tests.rs index bdb9a3d6e..da9a83ad7 100644 --- a/crates/eps/src/import/ai/style/tests.rs +++ b/crates/eps/src/import/ai/style/tests.rs @@ -1186,3 +1186,32 @@ fn convert_to_shape_settings_that_arent_read_keep_the_drawn_look() { format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", &[compound("Stack Style Filter", &[item])])); assert!(!art_styles(data.as_bytes())["Odd"].plain); } + +/// A style as a writer checks it: the reader's appearance, with a gradient fill as the object's own, +/// and nothing for styles it doesn't read. +#[test] +fn a_style_is_read_as_a_writer_checks_it() { + let read = |data: &str, own: Option<&Paint>| crate::import::read_art_style(data.as_bytes(), "Odd", own); + let style = |kids: &[String]| format!("/KnownStyle :\n(Odd) /Name ,\n/ActiveStyle :\n{}", compound("Chain Style Filter", kids)); + let gradient = fill("Bb\n1 (G) 0 0 0 1 1 0 0 1 0 0 1 Bg\n0 BB"); + let stack = compound("Stack Style Filter", std::slice::from_ref(&gradient)); + let data = style(&[stack, blend(1, 0.5)]); + let own = Paint::solid(vectorcraft_color::Color::WHITE); + let got = read(&data, Some(&own)).unwrap(); + assert_eq!(got.appearance.items.len(), 1); + assert_eq!(got.appearance.items[0].paint(), &own); + assert!(got.simple && !got.pathfinder); + // A gradient that isn't the object's own, a pattern, another name, two gradients: nothing. + assert!(read(&data, None).is_none()); + let pattern = style(&[compound("Stack Style Filter", &[fill("(Dots) 0 0 1 1 0 0 0 0 0 [1 0 0 1 0 0] p")])]); + assert!(read(&pattern, None).is_none()); + assert!(crate::import::read_art_style(data.as_bytes(), "Even", Some(&own)).is_none()); + assert!(read(&style(&[compound("Stack Style Filter", &[gradient.clone(), gradient])]), Some(&own)).is_none()); + // Damaged text: every cut of the style, and stray bytes, without a panic. + for cut in 0..data.len() { + let _ = read(data.get(..cut).unwrap_or_default(), Some(&own)); + } + for junk in ["(", ")", ";", "/KnownStyle :\n(Odd) /Name ,\n;;;;", "/KnownStyle :\n(Odd) /Name ,\n/Array : 1 /Real , ; (x) ,"] { + let _ = read(junk, Some(&own)); + } +} diff --git a/crates/eps/src/import/ai/tests.rs b/crates/eps/src/import/ai/tests.rs index e736fe6da..e227ec12b 100644 --- a/crates/eps/src/import/ai/tests.rs +++ b/crates/eps/src/import/ai/tests.rs @@ -3474,6 +3474,96 @@ fn a_tz_with_use_default_0_leaves_te_out() { assert_eq!(legacy_chars(setup, &["_F", "_S", "_M", "_G"], "\\047\\140\\200"), ["'`€", "'`€", "'`€", "’`Ä"]); } +/// Strings in a composite font `base-CMap` decode by that predefined CMap (codes to CIDs, then the +/// character collection's CIDs to Unicode), and the font is `base`; `TE`'s encoding does not apply +/// to them. Codes the CMap leaves out are dropped. A font whose name ends in no predefined CMap +/// decodes byte by byte. +#[test] +fn legacy_type_in_a_cjk_font_decodes_by_its_cmap() { + let typed = |font: &str, chars: &str| point_type("", chars).replace("/_Helvetica", &format!("/{font}")); + let cases: &[(&str, &str, &str)] = &[ + // 日 is 0x93FA and 本 0x967B in Shift-JIS; `\r` ends a paragraph. + ("83pv-RKSJ-H", "\\223\\372\\226{A\\r\\223\\372", "日本A\n日"), + // A tab stays a tab, also after a UTF-16 string's last whole code. + ("90ms-RKSJ-H", "\\223\\372\\tA", "日\tA"), + ("H", "F|\\tF|", "日\t日"), + ("UniJIS-UTF16-H", "e\\345\\t", "日\t"), + // One-byte codes in the Mac Shift-JIS CMap: 0x5C yen, 0x7E tilde, 0x80 backslash, 0xFD + // copyright, and the underscore and grave accent. + ("83pv-RKSJ-H", "\\\\~\\200\\375_`", "¥~\\©_`"), + ("90ms-RKSJ-H", "\\\\~_`", "¥~_`"), + ("90ms-RKSJ-V", "\\223\\372", "日"), + ("90pv-RKSJ-V", "\\223\\372", "日"), + // JIS row and cell: 日 is 0x4652. + ("H", "F|", "日"), + ("V", "F|", "日"), + ("EUC-H", "\\306\\374", "日"), + ("GBK-EUC-H", "\\326\\320", "中"), + ("GBpc-EUC-H", "\\326\\320", "中"), + ("B5pc-H", "\\244\\244", "中"), + ("KSCms-UHC-H", "\\307\\321", "한"), + ("KSCpc-EUC-H", "\\307\\321", "한"), + ("UniJIS-UCS2-H", "e\\345", "日"), + ("UniGB-UTF8-H", "\\344\\270\\255", "中"), + // An encoded U+FFFD is a character; the invalid byte 0xFF is dropped. + ("UniGB-UTF8-H", "\\357\\277\\275\\377\\344\\270\\255", "\u{fffd}中"), + ("UniJIS-UTF32-H", "\\000\\000e\\345", "日"), + ("UniAKR-UTF16-H", "\\254\\000", "가"), + // CMaps VectorCraft doesn't carry: read with the nearest carried one, row-and-cell codes in + // their EUC form (中 is 0x5650 in GB 2312, 한 0x4751 in KS X 1001), or byte by byte. + ("78-RKSJ-H", "\\223\\372", "日"), + ("RKSJ-V", "\\223\\372", "日"), + ("GB-H", "VP", "中"), + ("KSC-V", "GQ", "한"), + ("GBT-H", "AB", "AB"), + // Codes are as long as the code space range they fall in, and unmapped ones are dropped: + // 0x8540 as two bytes, and 0xFD and EUC's 0x8F (in no range) as one. + ("90ms-RKSJ-H", "\\205\\100\\223\\372A\\375B", "日AB"), + ("EUC-H", "\\217A\\306\\374", "A日"), + ]; + let art: String = cases.iter().map(|(cmap, chars, _)| typed(&format!("_Test-W3-{cmap}"), chars)).collect(); + let art = [art, typed("_Test-W3-Identity-H", "\\223"), typed("_Test-W3-UniFoo-UTF8-H", "\\223"), typed("_Helvetica", "\\223")].concat(); + let data = type_stream7(&art).replace("%%BeginSetup\n%%EndSetup", "%%BeginSetup\n[147/Adieresis\nTE\n%%EndSetup"); + let texts = art_texts(&read(data.as_bytes()).unwrap().doc); + let chars: Vec = texts.iter().map(|t| t.plain_text()).collect(); + let mut want: Vec<&str> = cases.iter().map(|(_, _, want)| *want).collect(); + want.extend(["Ä", "Ä", "Ä"]); + assert_eq!(chars, want); + let test = crate::import::family_style("Test-W3"); + for (t, (cmap, _, _)) in texts.iter().zip(cases) { + assert_eq!((&t.runs[0].style.font_family, &t.runs[0].style.font_style), (&test.0, &test.1), "{cmap}"); + } + assert_eq!(texts[0].paragraph_count(), 2); + assert_ne!(texts[cases.len()].runs[0].style.font_style, test.1, "an Identity CMap is not read"); + assert_ne!(texts[cases.len() + 1].runs[0].style.font_style, test.1, "`UniFoo` is not a character collection"); +} + +/// A font whose `TZ` gives encoding pairs is not a composite font, even when its name ends in a +/// CMap name: its strings decode byte by byte, and the font keeps its whole name. A `TZ` without +/// encoding pairs only names a composite font. +#[test] +fn a_re_encoded_font_ending_in_a_cmap_name_is_not_composite() { + let setup = "%AI3_BeginEncoding: _F-Bold-H F-Bold-H\n[ 39/quotesingle\n/_F-Bold-H/F-Bold-H 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n"; + assert_eq!(legacy_chars(setup, &["_F-Bold-H"], "F|'"), ["F|'"]); + let setup = "%AI3_BeginEncoding: _G-Bold-H G-Bold-H\n[ 65/A\n/_G-Bold-H/G-Bold-H 0 0 1 TZ\n%AI3_EndEncoding AdobeType\n"; + assert_eq!(legacy_chars(setup, &["_G-Bold-H"], "F|"), ["F|"]); + let setup = "%AI3_BeginEncoding: _R-W3-83pv-RKSJ-H R-W3-83pv-RKSJ-H\n[/_R-W3-83pv-RKSJ-H/R-W3-83pv-RKSJ-H 0 1 1 TZ\n%AI3_EndEncoding AdobeType\n"; + assert_eq!(legacy_chars(setup, &["_R-W3-83pv-RKSJ-H"], "\\223\\372"), ["日"]); +} + +/// In a font with a Unicode CMap, a one-byte `(\r)` string, or a 0x0D byte after a string's last +/// whole UTF-16 code, still ends a paragraph, as in other fonts, and an overflow `(\r) TX` still +/// ends the story. +#[test] +fn legacy_type_in_a_utf16_font_keeps_its_paragraph_breaks() { + let art = point_type("", "e\\345") + .replace("/_Helvetica", "/_Test-W3-UniJIS-UTF16-H") + .replace("TO\n", "(\\r) Tx\n(e\\345\\r) Tx\n(e\\345) Tx\n(\\r) TX\nTO\n"); + let texts = legacy_texts(&art); + assert_eq!(texts[0].plain_text(), "日\n日\n日"); + assert_eq!(texts[0].paragraph_count(), 3); +} + fn art_texts(doc: &Document) -> Vec { art(doc).iter().filter_map(|n| if let NodeKind::Text(t) = &n.kind { Some((**t).clone()) } else { None }).collect() } @@ -3509,6 +3599,32 @@ fn legacy_type_keeps_its_spacing_hanging_punctuation_and_hyphenation() { assert_eq!(texts[1].para_at(0), two); } +/// `Tg` (§15.3.4) turns binding punctuation on (1 and 3) or off (0 and 2); VectorCraft opens it on +/// as Burasagari's Force setting. Each paragraph of point type and area type keeps the value in +/// force at its first character, and it lasts into the next text object. +#[test] +fn legacy_type_keeps_its_binding_punctuation() { + use vectorcraft_doc::Burasagari; + let burasagari = |attrs: &str| legacy_texts(&point_type(attrs, "Hi"))[0].para_at(0).burasagari; + assert_eq!(burasagari(""), Burasagari::None); + for on in ["1", "3"] { + assert_eq!(burasagari(&format!("0 Tg\n{on} Tg")), Burasagari::Forced, "{on} Tg"); + } + for off in ["0", "2"] { + assert_eq!(burasagari(&format!("1 Tg\n{off} Tg")), Burasagari::None, "{off} Tg"); + } + let two_paragraphs = point_type("1 Tg", "One\\r").replace("TO\n", "0 Tg\n(Two) Tx\nTO\n"); + let texts = legacy_texts(&format!("{two_paragraphs}{}{}", point_type("3 Tg", "Three"), point_type("", "Four"))); + let each: Vec = vec![texts[0].para_at(0).burasagari, texts[0].para_at(1).burasagari, texts[1].para_at(0).burasagari]; + assert_eq!(each, [Burasagari::Forced, Burasagari::None, Burasagari::Forced]); + assert_eq!(texts[2].para_at(0).burasagari, Burasagari::Forced, "the next text object keeps it"); + let frame = "10 10 m\n110 10 L\n110 110 L\n10 110 L\nN"; + let area = format!("1 To\n1 0 0 1 10 110 0 Tp\n{frame}\nTP\n0 Tr\n0 0 0 1 k\n/_Helvetica 12 Tf\n1 Tg\n(Area) Tx\nTO\n"); + let texts = legacy_texts(&area); + assert!(matches!(texts[0].kind, vectorcraft_doc::TextKind::Area { .. })); + assert_eq!(texts[0].para_at(0).burasagari, Burasagari::Forced, "area type"); +} + /// `TV` (§15.3.4) sets the writing direction of point type, area type, type on a path and linked /// areas, and lasts into the next text object until it changes. #[test] @@ -3649,6 +3765,10 @@ fn legacy_type_attributes_with_bad_operands_stay_as_they_were() { assert_eq!(p.justify, vectorcraft_doc::Justify::Right); assert_eq!((p.first_line_indent, p.left_indent, p.right_indent), (5.0, 6.0, 7.0)); assert_eq!(texts[0].runs[0].style.kerning, Some(0.0), "kerning stays off"); + let texts = legacy_texts(&point_type("1 Tg\n4 Tg\n-1 Tg\n0.5 Tg\n/x Tg\n(0) Tg\nTg", "Hi")); + assert_eq!(texts[0].para_at(0).burasagari, vectorcraft_doc::Burasagari::Forced, "binding punctuation stays on"); + let texts = legacy_texts(&point_type("0 Tg\n4 Tg\n-1 Tg\n2.5 Tg", "Hi")); + assert_eq!(texts[0].para_at(0).burasagari, vectorcraft_doc::Burasagari::None, "binding punctuation stays off"); let texts = legacy_texts(&point_type("1 TV\n1 Tv\n1 Ty\n2 TV\n/x TV\nTV\n-1 Tv\n(x) Tv\n0.5 Ty\n2 TY", "Hi")); let st = &texts[0].runs[0].style; assert!(texts[0].vertical && st.standard_vertical_roman && st.tate_chu_yoko); @@ -5312,10 +5432,15 @@ fn editing_data_past_the_limit_is_refused() { fn many_zstandard_frames_are_read_in_one_pass_and_capped() { let frame = zstd(b" "); let frames = |n: usize| -> Vec { [&b"%AI24_ZStandard_Data"[..], &frame.repeat(n), &[0; 64]].concat() }; - let started = std::time::Instant::now(); - assert_eq!(decode_private(&frames(4000)).unwrap().len(), 4000); - assert!(decode_private(&frames(50_000)).unwrap_err().contains("Zstandard")); - assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed()); + let (few, many) = (frames(4000), frames(50_000)); + let before = padding_looks(); + assert_eq!(decode_private(&few).unwrap().len(), 4000); + assert!(decode_private(&many).unwrap_err().contains("Zstandard")); + // The first byte of each of the 4,000 frames and of the 4,097 read before the cap, and the + // 64 zeros of padding once: looking past the frames to the padding before each frame would + // look at 64 or more bytes per frame. + let looked = padding_looks() - before; + assert!(looked <= 2 * (4000 + 4097 + 64), "looked at {looked} bytes for padding"); } /// A Zstandard frame that ends in a zero byte keeps it: only the zeros after the last frame are @@ -6752,6 +6877,134 @@ fn an_envelope_that_doesnt_come_out_as_drawn_keeps_its_drawn_art() { assert_eq!(r.warnings, [super::envelope::DRAWN]); } +/// A document of `n` warped text objects, with the art the live warp makes of each, in its look. +fn warped_type_doc(n: usize) -> (Document, Vec) { + use vectorcraft_doc::{Appearance, Effect, TextObject}; + let mut doc = Document::new(400.0, 200.0); + let mut looks = vec![]; + let mut layer = Node::layer(doc.alloc_id(), "Art", vectorcraft_doc::LayerColor::Preset(0)); + for i in 0..n { + let t = TextObject::point(kurbo::Point::new(20.0, 40.0 + 40.0 * i as f64), "TILT", Default::default()); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let mut appearance = Appearance::default(); + appearance.effects.push(Effect { id: "warp.arc".into(), params: serde_json::json!({"bend": 40.0}), visible: true }); + text.appearance = appearance.into(); + text.name = Some(format!("Type {i}")); + text.locked = i == 0; + text.visible = i != 0; + let mut look = vectorcraft_effects::expand_outlined(None, &text, None).unwrap(); + look.id = doc.alloc_id(); + looks.push(super::envelope::WarpedLook { slot: text.id, look, left_out: None }); + layer.children_mut().unwrap().push(Arc::new(text)); + } + doc.layers = vec![Arc::new(layer)]; + (doc, looks) +} + +/// Warped text objects share the import's budget of checks. One past it keeps its drawn art with its +/// text object's name and state, as one that fails the check does, and its box and wrap setting. +#[test] +fn warped_type_past_the_budget_comes_in_as_its_look() { + let (mut doc, mut looks) = warped_type_doc(2); + let (first, second) = (looks[0].slot, looks[1].slot); + let left_out = Rect::new(1.0, 2.0, 3.0, 4.0); + looks[1].left_out = Some(left_out); + let wrap = vectorcraft_doc::TextWrap::default(); + doc.node_mut(second).unwrap().extras_mut().wrap = Some(wrap); + let warped = super::envelope::WarpedType { looks, checks: 1, ..Default::default() }; + let mut restyled = vec![Rect::new(5.0, 6.0, 7.0, 8.0), left_out]; + assert_eq!(warped.settle(&mut doc, Some, &Default::default(), &mut restyled), 1); + assert_eq!(restyled, [Rect::new(5.0, 6.0, 7.0, 8.0)]); + assert!(matches!(doc.node(first).unwrap().kind, NodeKind::Text(_))); + let kept = doc.node(second).unwrap(); + assert!(matches!(kept.kind, NodeKind::Group { .. } | NodeKind::Path { .. }), "{kept:#?}"); + assert_eq!((kept.name.as_deref(), kept.visible, kept.locked), (Some("Type 1"), true, false)); + assert_eq!(kept.extras().wrap, Some(wrap)); +} + +/// The budget for warped type is what the envelopes left: their checks and the page's share they +/// spent, with the page they are held to. +#[test] +fn warped_type_gets_what_the_envelopes_left_of_the_budget() { + let flat = vectorcraft_doc::live::EnvelopeKind::Mesh { + rows: 1, + cols: 1, + points: vec![Point::new(40.0, 20.0), Point::new(160.0, 20.0), Point::new(40.0, 80.0), Point::new(160.0, 80.0)], + handles: vec![], + }; + let exact = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &drawn_content(&flat)))); + let beside = format!("{RED}{}f\n", rect_path(40.5, 20.0, 160.5, 80.0)); + let shifted = read_ok(&layer(&envelope_of(&straight_mesh(1, 1, (120.0, 60.0)), ENVELOPE_KEYS, &envelope_content(), 2, &beside))); + for r in [&exact, &shifted] { + assert_eq!(r.warped_type.checks, super::envelope::MAX_CHECKED - 1); + assert!(r.warped_type.page.is_some()); + } + live_envelope(&art(&shifted.doc)[0]); + assert!(shifted.warped_type.spent.any > exact.warped_type.spent.any); +} + +/// In a file with an artboard list, warped type is held to the first artboard, not to the crop marks. +#[test] +fn warped_type_is_held_to_the_first_artboard() { + let boards = "%_/Document :\n%_/Dictionary :\n%_/Array :\n\ + %_/Dictionary :\n%_0 200 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_300 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n\ + %_/Dictionary :\n%_400 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_600 -150 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_; ,\n\ + %_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"; + let r = read_ok(&format!("{boards}{}", layer(&format!("{RED}{}f\n", rect_path(20.0, 20.0, 40.0, 40.0))))); + assert_eq!(r.doc.artboards[0].rect, Rect::new(0.0, 0.0, 300.0, 200.0)); + assert_eq!(r.warped_type.page, Some(Rect::new(0.0, 0.0, 300.0, 200.0))); +} + +/// Type whose Warp is hidden draws no warp, so it stays type even with no check left. +#[test] +fn type_with_a_hidden_warp_stays_type() { + let (mut doc, looks) = warped_type_doc(1); + let slot = looks[0].slot; + let mut appearance = (*doc.node(slot).unwrap().appearance).clone(); + appearance.effects[0].visible = false; + doc.node_mut(slot).unwrap().appearance = appearance.into(); + let warped = super::envelope::WarpedType { looks, checks: 0, ..Default::default() }; + assert_eq!(warped.settle(&mut doc, Some, &Default::default(), &mut vec![]), 0); + assert!(matches!(doc.node(slot).unwrap().kind, NodeKind::Text(_))); +} + +/// A warped frame of threaded type stays type, whatever its look draws: the thread's frames share +/// one story. So does a slot that became no text object. +#[test] +fn warped_type_in_a_thread_stays_type() { + let (mut doc, looks) = warped_type_doc(2); + let (first, second) = (looks[0].slot, looks[1].slot); + let warped = super::envelope::WarpedType { looks, ..Default::default() }; + assert_eq!(warped.settle(&mut doc, |id| (id != second).then_some(id), &[first].into(), &mut vec![]), 0); + assert!(matches!(doc.node(first).unwrap().kind, NodeKind::Text(_))); + assert!(matches!(doc.node(second).unwrap().kind, NodeKind::Text(_))); +} + +/// What is left of the page's share of the budget decides, and the page's share counts only for +/// what the document shows. +#[test] +fn warped_type_on_the_page_spends_what_is_left_of_the_budget() { + use crate::import::native::compare::{MAX_DIFFERENCE, MAX_SOLID_DIFFERENCE}; + let (doc, mut looks) = warped_type_doc(2); + // The second object shows; the first is hidden. Each look draws a little more than its object. + let mark = read_ok(&layer(&format!("{RED}{}f\n", rect_path(2.0, 2.0, 2.6, 2.6)))); + let mark = art(&mark.doc)[0].clone(); + for (i, w) in looks.iter_mut().enumerate() { + w.look.visible = true; + w.look = Node::group(vectorcraft_doc::NodeId(u64::MAX - 10 - i as u64), vec![Arc::new(w.look.clone()), mark.clone()]); + } + let copy = |w: &super::envelope::WarpedLook| super::envelope::WarpedLook { slot: w.slot, look: w.look.clone(), left_out: None }; + let page = Some(Rect::new(0.0, 60.0, 60.0, 100.0)); + let settle = |w: &super::envelope::WarpedLook, spent| { + let warped = super::envelope::WarpedType { looks: vec![copy(w)], checks: 1, spent, page }; + warped.settle(&mut doc.clone(), Some, &Default::default(), &mut vec![]) + }; + let spent = || super::envelope::Spent { any: MAX_DIFFERENCE / 2.0, solid: MAX_SOLID_DIFFERENCE / 2.0 }; + assert_eq!(settle(&looks[1], Default::default()), 0); + assert_eq!(settle(&looks[1], spent()), 1); + assert_eq!(settle(&looks[0], spent()), 0); +} + /// An envelope that comes out close to its drawn art, but with more of the page differing than the /// page check can take from the live envelopes (its drawn art 2 pt to the side on a 200 × 100 /// page), keeps the drawn art, so the file still opens from its layers. @@ -7145,19 +7398,25 @@ pub(super) fn blend_file(spine: &str, ids: [Option<&str>; 2], record: &str) -> S /// [`blend_file`] with the left key in RGB `from`, the right one in `to`, and the steps drawn /// between their colors. fn blend_file_colored(spine: &str, ids: [Option<&str>; 2], record: &str, from: [f64; 3], to: [f64; 3]) -> String { + blend_file_apart(spine, ids, record, from, to, 100.0, 3) +} + +/// [`blend_file_colored`] with the right key `apart` points right of the left one and `between` +/// steps drawn between them. +fn blend_file_apart(spine: &str, ids: [Option<&str>; 2], record: &str, from: [f64; 3], to: [f64; 3], apart: f64, between: usize) -> String { let mut art = String::from("%_0 Ae\n%_u\n"); art += &square(0.0, 40.0, 20.0, from, true); art += &blend_part("K", ids[0]); - art += &square(100.0, 40.0, 20.0, to, true); + art += &square(apart, 40.0, 20.0, to, true); art += &blend_part("K", ids[1]); art += spine; art += "%_N\n"; art += &blend_part("S", None); art += "%_U\n%_9 () XW\nu\n"; - for i in 0..5 { - let t = i as f64 / 4.0; + for i in 0..between + 2 { + let t = i as f64 / (between + 1) as f64; let rgb = [0, 1, 2].map(|c| from[c] + (to[c] - from[c]) * t); - art += &square(t * 100.0, 40.0, 20.0, rgb, false); + art += &square(t * apart, 40.0, 20.0, rgb, false); } art += "U\n9 () XW\n0 Ae\n"; art += record; @@ -7233,7 +7492,7 @@ fn a_blends_spine_written_backwards_keeps_its_keys_order() { /// A blend's spacing comes from its record: Specified Distance with its distance (25 pt from one /// step's edge to the corresponding edge of the next lays three out between keys 100 pt apart), -/// Smooth Color (keys 5/255 apart in blue take three steps), and Specified Steps with the steps +/// Smooth Color (keys 3/255 apart in blue take three steps), and Specified Steps with the steps /// counted from the drawn ones, whatever its record's word 4 holds. The distance counts along the /// spine between the keys, also where the spine runs on past the last key. #[test] @@ -7241,7 +7500,7 @@ fn a_blend_keeps_its_spacing() { use vectorcraft_doc::live::BlendSpacing; let straight = "%_10 50 m\n%_110 50 L\n"; let longer = "%_10 50 m\n%_110 50 L\n%_1010 50 L\n"; - let (red, nearly_red, blue) = ([1.0, 0.0, 0.0], [1.0, 0.0, 5.0 / 255.0], [0.0, 0.0, 1.0]); + let (red, nearly_red, blue) = ([1.0, 0.0, 0.0], [1.0, 0.0, 3.0 / 255.0], [0.0, 0.0, 1.0]); for (spine, record, (from, to), spacing) in [ (straight, blend_record_spaced(&['A', 'B'], 2, 25.0, 3), (red, blue), BlendSpacing::Distance(25.0)), (longer, blend_record_spaced(&['A', 'B'], 2, 25.0, 3), (red, blue), BlendSpacing::Distance(25.0)), @@ -7257,6 +7516,23 @@ fn a_blend_keeps_its_spacing() { } } +/// Smooth Color blends of two 20 pt squares 200 pt apart come in live with the steps drawn +/// between them: 254 from red to blue, and 9 between two red squares. +#[test] +fn a_smooth_color_blend_comes_in_with_its_drawn_steps() { + use vectorcraft_doc::live::BlendSpacing; + let (red, blue) = ([1.0, 0.0, 0.0], [0.0, 0.0, 1.0]); + for (to, between) in [(blue, 254), (red, 9)] { + let record = blend_record_spaced(&['A', 'B'], 0, 4.0, between as u32); + let body = blend_file_apart("%_10 50 m\n%_210 50 L\n", [Some("A"), Some("B")], &record, red, to, 200.0, between); + let (blend, warnings) = read_blend(&body); + let NodeKind::Blend { children, spec } = &blend.kind else { panic!("a live blend for {between} steps: {:?} {warnings:?}", blend.kind) }; + assert_eq!(spec.spacing, BlendSpacing::SmoothColor); + assert_eq!(vectorcraft_doc::blend::blend_expand(children, spec).len(), between + 2); + assert!(!warnings.iter().any(|w| w == super::blend::DRAWN), "{between}: {warnings:?}"); + } +} + /// A blend whose spacing doesn't lay out its drawn steps keeps them: a distance that lays out /// nine, and Smooth Color between red and blue (more steps than the three drawn); and so does a /// spacing the reader doesn't know or a distance that isn't a positive number. @@ -8095,6 +8371,172 @@ fn a_brushs_art_places_a_version_7_patterns_tiles_with_its_operands() { assert!(!vectorcraft_brush::library(&r.doc).iter().any(|b| b.name == "Spray"), "{:?}", r.warnings); } +/// A Scatter brush whose 15 numbers start with 1 colors its art with Tints of the stroke, and one +/// whose 15 numbers start with 0 keeps the art's colors. The field before the art's name doesn't +/// count: a brush whose first field is 1 is read by its 15 numbers, and one with 8 numbers, as older +/// files write their brushes, isn't read as a Scatter brush. +#[test] +fn a_scatter_brush_in_tints_comes_in_tinted() { + use vectorcraft_brush::{BrushKind, Colorization}; + let read = |params: &str| { + let r = read_ok(&format!("{}{}{}", brush_art("Dot art", 4.0), plugin_object(SCATTER, "Spray", params), layer(""))); + vectorcraft_brush::library(&r.doc).into_iter().find(|b| b.name == "Spray") + }; + for (field, first, colorization) in + [("2", "1", Colorization::Tints), ("2", "0", Colorization::None), ("1", "1", Colorization::Tints), ("1", "0", Colorization::None)] + { + let params = format!("{field} /Dot art/ 5 0 0 0 / {first} 1 1 0 1 1 0 0 0 0 0 0 0 0 0"); + let Some(b) = read(¶ms) else { panic!("{params}: not read") }; + let BrushKind::Scatter(s) = b.kind else { panic!("scatter") }; + assert_eq!(s.colorization, colorization, "{params}"); + } + let older = "1 /Dot art/ 0 1 / 1 0 1 0 1 0 0 0"; + assert_eq!(brush::parse_tool_object(brush::Tool::Scatter, "Spray", older), Err(brush::BrushError::Values)); + assert!(read(older).is_none(), "8 numbers aren't a Scatter brush's"); +} + +/// A brush's art section `name`: in a group, a frame (a rectangle from `frame.0` to `frame.1` on +/// both axes, painted by `op` and followed by `after`) and a filled square from 0 to 4. +fn framed_art(name: &str, frame: (f64, f64), op: &str, after: &str) -> String { + format!( + "%AI8_BeginBrushPattern\n({name})\n0 A\n0 Xw\nu\n0 XR\n{}{op}\n{after}0 g\n{}f\nU\n%AI8_EndBrushPattern\n", + rect_path(frame.0, frame.0, frame.1, frame.1), + rect_path(0.0, 0.0, 4.0, 4.0) + ) +} + +/// The frame of a brush's art, an unnamed, unpainted rectangle at the art's bounds, isn't part of +/// the art, closed or open with its first corner again at the end. A frame wider than the art is +/// part of it, and so are a painted rectangle and a named one, which aren't frames. +#[test] +fn a_brushs_frame_is_left_out_only_at_the_arts_bounds() { + use vectorcraft_brush::BrushKind; + let named = "%_/ArtDictionary :\n%_/XMLUID : (Edge) ; (AI10_ArtUID) ,\n%_;\n%_\n"; + for (why, frame, op, after, kept) in [ + ("exact", (0.0, 4.0), "n", "", false), + ("open", (0.0, 4.0), "N", "", false), + ("wider", (-1.0, 5.0), "n", "", true), + ("filled", (0.0, 4.0), "f", "", true), + ("named", (0.0, 4.0), "n", named, true), + ] { + let params = "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0"; + let r = read_ok(&format!("{}{}{}", framed_art("Dot art", frame, op, after), plugin_object(SCATTER, "Spray", params), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let BrushKind::Scatter(s) = brush_named(&lib, "Spray") else { panic!("scatter") }; + if kept { + assert!(matches!(s.art.kind, NodeKind::Group { .. }), "{why}: {:#?}", s.art); + assert_eq!(s.art.children().map(|c| c.len()), Some(2), "{why}"); + } else { + assert_eq!(s.art.geometric_bounds().map(|b| b.width()), Some(4.0), "{why}: {:#?}", s.art); + assert!(matches!(s.art.kind, NodeKind::Path { .. }), "{why}: the square alone: {:#?}", s.art); + } + } +} + +/// A brush's art group that is hidden, locked, isolated or has a note keeps that state when its +/// frame is left out: the group stays, without the frame, rather than becoming its one other object. +#[test] +fn a_brushs_frame_leaves_the_groups_own_state() { + use vectorcraft_doc::{Appearance, FillLayer, NodeId}; + let square = Rect::new(0.0, 0.0, 4.0, 4.0); + let paint = FillLayer::new(Paint::solid(Color::BLACK)); + let ap = Appearance { items: vec![AppearanceItem::Fill(paint)], effects: vec![], contents_index: None }; + let dot = Node::path(NodeId(2), vectorcraft_geom::shapes::rectangle(square), ap); + let frame = Node::path(NodeId(1), vectorcraft_geom::shapes::rectangle(square), Default::default()); + let group = Node::group(NodeId(3), vec![Arc::new(frame), Arc::new(dot)]); + assert!(matches!(super::brush::without_frame(group.clone()).kind, NodeKind::Path { .. }), "a plain group is its one object"); + // A closed path through the four corners across the square, not along its sides, isn't a frame. + let corners = [Point::new(0.0, 0.0), Point::new(4.0, 4.0), Point::new(4.0, 0.0), Point::new(0.0, 4.0)]; + let crossing = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&corners, true)); + let bowtie = Node::path(NodeId(4), crossing, Default::default()); + let dot = group.children().and_then(|c| c.last().cloned()).unwrap(); + let crossed = Node::group(NodeId(5), vec![Arc::new(bowtie), dot]); + assert_eq!(super::brush::without_frame(crossed).children().map(|c| c.len()), Some(2), "a path across the corners stays"); + // An open path along three sides isn't a frame; one that comes back to its first corner is. + let sides = [Point::new(0.0, 0.0), Point::new(4.0, 0.0), Point::new(4.0, 4.0), Point::new(0.0, 4.0)]; + let around = [sides.as_slice(), &sides[..1]].concat(); + for (why, anchors, frame) in [("three sides", sides.as_slice(), false), ("back to the first corner", around.as_slice(), true)] { + let open = vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(anchors, false)); + let dot = group.children().and_then(|c| c.last().cloned()).unwrap(); + let g = Node::group(NodeId(6), vec![Arc::new(Node::path(NodeId(7), open, Default::default())), dot]); + let left = if frame { None } else { Some(2) }; + assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), left, "{why}"); + } + // A rectangle with settings of its own (a note, Show Center, hidden) isn't a frame. + for why in ["note", "show center", "hidden"] { + let mut g = group.clone(); + if let Some(Some(first)) = g.children_mut().map(|c| c.first_mut()) { + let first = Arc::make_mut(first); + match why { + "note" => first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() })), + "show center" => { + first.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { show_center: Some(true), ..Default::default() })) + } + _ => first.visible = false, + } + } + assert_eq!(super::brush::without_frame(g).children().map(|c| c.len()), Some(2), "{why}: the rectangle stays"); + } + for why in ["hidden", "locked", "isolated", "note"] { + let mut g = group.clone(); + (g.visible, g.locked, g.isolate) = (why != "hidden", why == "locked", why == "isolated"); + if why == "note" { + g.extras_mut().attrs = Some(Box::new(vectorcraft_doc::ObjectAttributes { note: "edge".into(), ..Default::default() })); + } + let out = super::brush::without_frame(g); + assert!(matches!(out.kind, NodeKind::Group { .. }), "{why}: {out:#?}"); + assert_eq!(out.children().map(|c| c.len()), Some(1), "{why}: the frame left out"); + assert_eq!((out.visible, out.locked, out.isolate), (why != "hidden", why == "locked", why == "isolated"), "{why}"); + assert_eq!(out.extras().attrs.as_ref().map(|a| a.note.as_str()), (why == "note").then_some("edge"), "{why}"); + } +} + +/// Leaving out a frame that is exactly the art's bounds doesn't move what a Scatter or Art brush +/// draws: the pieces along a path are where they are with the frame kept. +#[test] +fn a_brushs_frame_at_the_arts_bounds_draws_the_same_without_it() { + use vectorcraft_brush::{ArtBrush, Brush, BrushKind, Scatter}; + let scatter = ("Spray", "Dot art", SCATTER, "2 /Dot art/ 5 0 0 0 / 0 1 1 0 1 1 0 0 0 0 0 0 0 0 0"); + for (name, art_name, tool, params) in [scatter, ("Ribbon", "Ribbon art", ART_BRUSH, RIBBON)] { + let r = read_ok(&format!("{}{}{}", framed_art(art_name, (0.0, 4.0), "n", ""), plugin_object(tool, name, params), layer(""))); + let lib = vectorcraft_brush::library(&r.doc); + let read = lib.iter().find(|b| b.name == name).cloned().unwrap(); + let art = match &read.kind { + BrushKind::Scatter(s) => s.art.clone(), + BrushKind::Art(a) => a.art.clone(), + k => panic!("{name}: {k:?}"), + }; + let bounds = art.geometric_bounds().unwrap(); + assert_eq!((bounds.width(), bounds.height()), (4.0, 4.0), "{name}"); + assert!(matches!(art.kind, NodeKind::Path { .. }), "{name}: the frame left out: {art:#?}"); + let frame = Node::path(vectorcraft_doc::NodeId(90), vectorcraft_geom::shapes::rectangle(bounds), Default::default()); + let framed_art = Node::group(vectorcraft_doc::NodeId(91), vec![Arc::new(frame), Arc::new(art)]); + let kind = match &read.kind { + BrushKind::Scatter(s) => BrushKind::Scatter(Scatter { art: framed_art, ..s.clone() }), + BrushKind::Art(a) => BrushKind::Art(ArtBrush { art: framed_art, ..a.clone() }), + k => panic!("{name}: {k:?}"), + }; + let framed = Brush { kind, ..read.clone() }; + let mut line = kurbo::BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((100.0, 30.0)); + let stroke = vectorcraft_doc::StrokeLayer::new(Paint::solid(Color::BLACK), 1.0); + let at = |b: &Brush| -> Vec<[i64; 4]> { + vectorcraft_brush::stroke_pieces(&r.doc, b, &line, &stroke) + .iter() + .filter_map(|n| n.geometric_bounds()) + .map(|b| [b.x0, b.y0, b.x1, b.y1].map(|v| (v * 1000.0).round() as i64)) + .collect() + }; + let (without, with) = (at(&read), at(&framed)); + assert!(!without.is_empty(), "{name}"); + if name == "Spray" { + assert!(without.len() > 10, "{without:?}"); + } + assert_eq!(without, with, "{name}"); + } +} + /// A file without brush objects keeps the default brushes. #[test] fn a_file_without_brushes_keeps_the_default_ones() { diff --git a/crates/eps/src/import/native/build.rs b/crates/eps/src/import/native/build.rs index 698201be5..bdb99e94c 100644 --- a/crates/eps/src/import/native/build.rs +++ b/crates/eps/src/import/native/build.rs @@ -209,9 +209,10 @@ pub(super) fn build_from( xml_ids, bindings, bindings_skipped, + warped_type, warnings, } = structure; - let restyled = left_out_of_comparisons(page, restyled, placeholders); + 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 // canvas scale ([`page_scale`]), is compared with the page scaled down to those units, and the // document read from it is scaled up by `unit`. @@ -284,16 +285,20 @@ pub(super) fn build_from( lone.push(vec![Arc::new(n.clone())]); } }); - // A `.ai` read as its PDF part has a page for each artboard, in a row: the type on each - // goes where the layers have that artboard. + // A `.ai` read as its PDF part has a page for each artboard it was saved with, in a row: + // the type on each goes where the layers have that artboard. let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); - let boards = page_boards(&doc.artboards, first, &pages); + let boards = page_boards(&doc, visible, first, &pages); + // The first page's type goes to the first artboard only when that artboard is the page's + // (every artboard has a page); otherwise it goes to `rect`, the art's box or the editing + // data's artboard. + let first_board = first > 0 || pages.len() == doc.artboards.len(); + let board_of = |i: usize| boards.as_ref()?.get(i).filter(|_| i > 0 || first_board); let page_moves: Vec<(Rect, Affine)> = pages .iter() .enumerate() .map(|(i, page)| { - let move_to_doc = - boards.and_then(|boards| boards.get(i)).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift); + let move_to_doc = board_of(i).map(|board| Affine::translate(board.rect.origin() - page.origin())).unwrap_or(shift); (*page, move_to_doc) }) .collect(); @@ -312,7 +317,7 @@ pub(super) fn build_from( } for object in shown.iter_mut().chain(hidden.iter_mut()).chain(lone.iter_mut()) { let on = page_of(object); - let shift = match on.and_then(|(i, page)| Some((boards?.get(i)?, page))) { + let shift = match on.and_then(|(i, page)| Some((board_of(i)?, page))) { Some((board, page)) => Affine::translate(board.rect.origin() - page.origin()), None => shift, }; @@ -322,7 +327,7 @@ pub(super) fn build_from( } // Pages cropped to the art are copies of the first, and a PDF saved with some of the // artboards can hold any of them. - let more = more_pages(boards.filter(|b| !cropped && b.len() == doc.artboards.len()), &pages, comparison_samples(visible)); + let more = more_pages(boards.as_deref().filter(|b| !cropped && b.len() == doc.artboards.len()), &pages, comparison_samples(visible)); // An EPS saved as its artboard has the art outside it left out of its page. let artboard_page = artboard.is_some_and(|a| [a.x0 - art_box.x0, a.y0 - art_box.y0, a.x1 - art_box.x1, a.y1 - art_box.y1].iter().all(|d| d.abs() <= 1.0)); @@ -412,6 +417,8 @@ pub(super) fn build_from( } // Area type flows around the wrap objects above it before it is compared with the page. resolve_text_wrap(&mut doc)?; + let bound_text = text_bound_slots(&doc, &bindings, &xml_ids); + settle_warped_type(warped_type, &mut doc, &filled, &threaded_frames, &bound_text, &mut restyled, &mut notes)?; if empty > 0 { notes.push(format!("{empty} {}", if frame.is_some() { TEXT_LEFT_OUT } else { TYPE_LEFT_OUT })); } @@ -551,6 +558,42 @@ fn bind_variables( notes.extend(ai::bindings_note(skipped)); } +/// The text object slots of `doc` that `bindings` binds to a text variable, each named by its XML id +/// (`ids`). +fn text_bound_slots(doc: &Document, bindings: &[ai::BindingRef], ids: &std::collections::HashMap>) -> BTreeSet { + bindings + .iter() + .filter(|b| doc.variables.variable(&b.variable).is_some_and(|v| v.kind == VariableKind::Text)) + .filter_map(|b| b.target.as_ref().and_then(|t| ids.get(t).copied().flatten())) + .collect() +} + +/// Warped type stays type where its live warp draws as its look, and where a text variable binds it +/// (`bound_text`, by slot). Elsewhere the look takes its place, and one note says how many did. +/// `filled` says what each slot became, and the comparisons with the page leave out the boxes in +/// `restyled`. Fails as [`resolve_text_wrap`] does when the looks give the wrap shapes too many +/// anchors. +fn settle_warped_type( + warped: ai::WarpedType, + doc: &mut Document, + filled: &std::collections::HashMap, + threaded: &BTreeSet, + bound_text: &BTreeSet, + restyled: &mut Vec, + notes: &mut Vec, +) -> Result<(), String> { + let made = |slot| match filled.get(&slot).map(|f| f.text.as_slice()).filter(|_| !bound_text.contains(&slot)) { + Some(&[one]) => Some(one), + _ => None, + }; + let replaced = warped.settle(doc, made, threaded, restyled); + if replaced > 0 { + notes.push(format!("{}: {replaced}", ai::WARPED_TYPE_DRAWN)); + resolve_text_wrap(doc)?; + } + Ok(()) +} + /// Most anchors copied into the wrap shapes of a file's area type ([`vectorcraft_doc::text::wrap_work`]). const MAX_WRAP_WORK: usize = 2_000_000; @@ -1356,18 +1399,47 @@ fn in_units(board: Option, page: &Document, unit: f64) -> bool { unit.is_finite() && unit > 1.0 && fits(unit) && !fits(1.0) } -/// The most artboards [`shown_artboard`] weighs, a render each. +/// The most artboards [`shown_artboard`] weighs for the first page, and the most [`page_boards`] +/// weighs for all the pages after it together, a render each. const MAX_BOARDS_WEIGHED: usize = 16; +/// Does artboard `a` have the size of `page`? +fn board_fits(a: &vectorcraft_doc::Artboard, page: &Rect) -> bool { + (a.rect.width() - page.width()).abs() <= 0.5 && (a.rect.height() - page.height()).abs() <= 0.5 +} + +/// The last of the artboards `boards` that each page of `pages` can show when every page shows +/// one, in the pages' order. A page after the first shows an artboard of its size. Working from +/// the last page back, each page's entry is the last artboard of its size before the next page's +/// entry. The first page's entry is the artboard before the second page's entry, or the last +/// artboard when there is one page. `None` when the pages after the first have no such artboards. +fn last_boards(boards: &[vectorcraft_doc::Artboard], pages: &[Rect]) -> Option> { + let mut last = vec![0; pages.len()]; + let mut end = boards.len(); + for (i, page) in pages.iter().enumerate().skip(1).rev() { + let j = (1..end).rev().find(|&j| boards.get(j).is_some_and(|a| board_fits(a, page)))?; + *last.get_mut(i)? = j; + end = j; + } + if let Some(l) = last.first_mut() { + *l = end.checked_sub(1)?; + } + Some(last) +} + /// Which of the artboards of `doc` (the layers' document) the page `page_rect` of `visible` shows, /// when `visible` has fewer pages than `doc` has artboards (a PDF saved with a range of them): of -/// those of the page's size (`fits`), the one whose art draws most like the page, the first of -/// them on a tie. Else (and for an EPS, whose page is its art) the first artboard (0). +/// those of the page's size (`fits`) that leave an artboard for each later page ([`last_boards`]), +/// the one whose art draws most like the page, the first of them on a tie. Else (and for an EPS, +/// whose page is its art) the first artboard (0). fn shown_artboard(doc: &Document, visible: &Document, page: Page, page_rect: Rect, fits: impl Fn(&Rect) -> bool) -> usize { if matches!(page, Page::Art) || visible.artboards.len() >= doc.artboards.len() { return 0; } - let sized: Vec = doc.artboards.iter().enumerate().filter(|(_, a)| fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect(); + let pages: Vec = visible.artboards.iter().map(|a| a.rect).collect(); + let last = last_boards(&doc.artboards, &pages).and_then(|l| l.first().copied()).unwrap_or(usize::MAX); + let sized: Vec = + doc.artboards.iter().enumerate().filter(|(i, a)| *i <= last && fits(&a.rect)).map(|(i, _)| i).take(MAX_BOARDS_WEIGHED).collect(); if let [only] = sized.as_slice() { return *only; } @@ -1407,13 +1479,46 @@ fn pages_touched(bounds: Rect, pages: &[Rect], margin: f64) -> Vec { pages.iter().enumerate().filter(|(_, p)| !b.intersect(**p).is_zero_area()).map(|(i, _)| i).collect() } -/// The artboards of the layers' document (`boards`) that the pages show, one a page from the -/// first page's (`first`), when they are the pages' sizes: every artboard, or those of a PDF saved -/// with some of them (`first` after the first artboard). -fn page_boards<'a>(boards: &'a [vectorcraft_doc::Artboard], first: usize, pages: &[Rect]) -> Option<&'a [vectorcraft_doc::Artboard]> { - let same = |(a, p): (&vectorcraft_doc::Artboard, &Rect)| (a.rect.width() - p.width()).abs() <= 0.5 && (a.rect.height() - p.height()).abs() <= 0.5; - let shown = boards.get(first..first.saturating_add(pages.len()))?; - ((first > 0 || pages.len() == boards.len()) && shown.iter().zip(pages).all(same)).then_some(shown) +/// The artboards of the layers' document (`doc`) that the pages of `visible` (`pages`) show, one a +/// page, the first page's being `first`, each of its page's size. The artboards are in the pages' +/// order. A page after the first shows an artboard after the previous page's, and only one that +/// leaves an artboard for each later page ([`last_boards`]). When several artboards can be the +/// page's (a PDF saved with some of them), the page shows the one whose art draws most like it, +/// the first of them on a tie. At most [`MAX_BOARDS_WEIGHED`] artboards are weighed for all the +/// later pages together, each page's candidates in order: a page takes the best of its candidates +/// that were weighed, or its first candidate when none of them were. `None` when the pages have no +/// such artboards. +fn page_boards(doc: &Document, visible: &Document, first: usize, pages: &[Rect]) -> Option> { + let boards = &doc.artboards; + let same = |j: usize, p: &Rect| boards.get(j).is_some_and(|a| board_fits(a, p)); + let last = last_boards(boards, pages)?; + if !pages.first().is_some_and(|p| same(first, p)) || last.first().is_none_or(|&l| first > l) { + return None; + } + let mut shown = vec![boards.get(first)?.clone()]; + let (mut after, mut weighed) = (first + 1, 0); + for (i, page) in pages.iter().enumerate().skip(1) { + let sized: Vec = (after..=*last.get(i)?).filter(|&j| same(j, page)).collect(); + let j = match sized.as_slice() { + [] => return None, + [only] => *only, + many => { + let weigh: Vec = many.iter().copied().take(MAX_BOARDS_WEIGHED.saturating_sub(weighed)).collect(); + weighed += weigh.len(); + let mut best: Option<(usize, f64)> = None; + for (j, a) in weigh.iter().filter_map(|&j| Some((j, boards.get(j)?))) { + let d = difference(visible, *page, doc, a.rect).any; + if best.is_none_or(|(_, b)| d < b) { + best = Some((j, d)); + } + } + best.map_or(many.first().copied(), |(j, _)| Some(j))? + } + }; + shown.push(boards.get(j)?.clone()); + after = j + 1; + } + Some(shown) } /// Drops the guides of artboards `doc` no longer has: an artboard guide goes with its artboard. @@ -1615,6 +1720,118 @@ mod candidate_tests { assert_eq!(more_pages(Some(&tiny_boards), &tiny, 1).len() + 1, MAX_PAGES_COMPARED); } + /// A page after the first doesn't take an artboard that a later page of another size needs, + /// even one that draws more like it. + #[test] + fn a_later_page_leaves_an_artboard_of_each_later_pages_size() { + let square = |doc: &mut Document, x: f64| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), 0, art).unwrap(); + }; + let board = |doc: &Document, x0: f64, width: f64| vectorcraft_doc::Artboard { + rect: Rect::new(x0, 0.0, x0 + width, 100.0), + ..doc.artboards[0].clone() + }; + // Artboards of 100, 100, 200, 100 and 100 points, a square on the fourth. + let mut doc = Document::new(100.0, 100.0); + doc.artboards = [(0.0, 100.0), (150.0, 100.0), (300.0, 200.0), (550.0, 100.0), (700.0, 100.0)].map(|(x, w)| board(&doc, x, w)).to_vec(); + square(&mut doc, 560.0); + // Pages of 100, 100 and 200 points, the second with the fourth artboard's square. + let mut page = Document::new(100.0, 100.0); + let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0), Rect::new(240.0, 0.0, 440.0, 100.0)]; + page.artboards = pages.iter().map(|r| board(&page, r.x0, r.width())).collect(); + square(&mut page, 130.0); + let shown = page_boards(&doc, &page, 0, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::>()); + assert_eq!(shown, Some(vec![0.0, 150.0, 300.0])); + } + + /// The pages after the first weigh at most [`MAX_BOARDS_WEIGHED`] artboards together: a page + /// takes the best of its candidates that were weighed, and a page left with none to weigh + /// takes its first candidate, even when a later candidate draws like it. + #[test] + fn the_later_pages_weigh_a_bounded_number_of_artboards() { + let square = |doc: &mut Document, x: f64| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), 0, art).unwrap(); + }; + let board = |doc: &Document, x0: f64, width: f64| vectorcraft_doc::Artboard { + rect: Rect::new(x0, 0.0, x0 + width, 100.0), + ..doc.artboards[0].clone() + }; + // Artboards 0 to 17 are 100 points wide, 150 apart; artboards 18 and 19 are 200 points + // wide. The second page's square is on artboard 17 and the third page's on artboard 19. + let n = MAX_BOARDS_WEIGHED + 2; + let mut doc = Document::new(100.0, 100.0); + let mut boards: Vec = (0..n).map(|i| board(&doc, i as f64 * 150.0, 100.0)).collect(); + let wide = n as f64 * 150.0; + boards.push(board(&doc, wide, 200.0)); + boards.push(board(&doc, wide + 250.0, 200.0)); + doc.artboards = boards; + square(&mut doc, (n - 1) as f64 * 150.0 + 10.0); + square(&mut doc, wide + 260.0); + let mut page = Document::new(100.0, 100.0); + let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0), Rect::new(240.0, 0.0, 440.0, 100.0)]; + page.artboards = pages.iter().map(|r| board(&page, r.x0, r.width())).collect(); + square(&mut page, 130.0); + square(&mut page, 250.0); + // The second page weighs artboards 1 to 16, which draw alike, and takes artboard 1. The + // third page has no weighing left and takes artboard 18. + let shown = page_boards(&doc, &page, 0, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::>()); + assert_eq!(shown, Some(vec![0.0, 150.0, wide])); + } + + /// The first page of a PDF of some artboards shows an artboard that leaves one for each later + /// page, even when a later artboard draws more like it. + #[test] + fn the_first_page_leaves_an_artboard_for_each_later_page() { + let square = |doc: &mut Document, x: f64| { + let art = Node::path( + doc.alloc_id(), + vectorcraft_geom::shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), + vectorcraft_doc::Appearance::basic( + vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK), + vectorcraft_color::Paint::None, + 0.0, + ), + ); + let layer = doc.layers[0].id; + doc.insert(Some(layer), 0, art).unwrap(); + }; + let board = |doc: &Document, x0: f64| vectorcraft_doc::Artboard { rect: Rect::new(x0, 0.0, x0 + 100.0, 100.0), ..doc.artboards[0].clone() }; + // Three artboards of one size, a square on the third. + let mut doc = Document::new(100.0, 100.0); + doc.artboards = [0.0, 150.0, 300.0].map(|x| board(&doc, x)).to_vec(); + square(&mut doc, 310.0); + // Two pages, the square on the first. + let mut page = Document::new(100.0, 100.0); + let pages = [Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(120.0, 0.0, 220.0, 100.0)]; + page.artboards = pages.iter().map(|r| board(&page, r.x0)).collect(); + square(&mut page, 10.0); + let fits = |r: &Rect| (r.width() - 100.0).abs() <= 0.5 && (r.height() - 100.0).abs() <= 0.5; + let first = shown_artboard(&doc, &page, Page::Artboard, pages[0], fits); + assert_eq!(first, 0); + let shown = page_boards(&doc, &page, first, &pages).map(|b| b.iter().map(|a| a.rect.x0).collect::>()); + assert_eq!(shown, Some(vec![0.0, 150.0])); + } + /// A linked image showing a placeholder (`XF`) waits on the page, whose PDF part draws its /// pixels: the layers aren't used without it. #[test] @@ -1703,6 +1920,34 @@ mod candidate_tests { } } + /// Warped type whose look takes its place, with the type's wrap setting, is held to the same + /// limit on wrap shapes: a file whose looks would copy more than `MAX_WRAP_WORK` anchors isn't + /// read from its layers. + #[test] + fn warped_type_replaced_by_its_look_is_held_to_the_wrap_limit() { + for (texts, ok) in [(150, true), (250, false)] { + let mut doc = Document::new(100.0, 100.0); + (0..texts).for_each(|_| _ = insert_area(&mut doc, 0, Rect::new(0.0, 0.0, 100.0, 100.0))); + let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(50.0, 50.0), "TILT", Default::default()); + let mut text = Node::new(doc.alloc_id(), NodeKind::Text(Box::new(t))); + let mut appearance = vectorcraft_doc::Appearance::default(); + appearance.effects.push(vectorcraft_doc::Effect { id: "warp.arc".into(), params: serde_json::json!({"bend": 40.0}), visible: true }); + text.appearance = appearance.into(); + text.extras_mut().wrap = Some(vectorcraft_doc::TextWrap::default()); + let top = doc.layers[0].children().map_or(0, Vec::len); + let id = doc.insert(Some(doc.layers[0].id), top, text).unwrap(); + let look = insert_wrap(&mut doc, Rect::new(40.0, 0.0, 60.0, 100.0), 10_000); + let look = doc.remove(look).unwrap(); + assert!(resolve_text_wrap(&mut doc).is_ok()); + let warped = ai::WarpedType::unchecked(vec![(id, (*look).clone())]); + let filled = std::collections::HashMap::from([(id, Filled { text: vec![id], objects: vec![id] })]); + let mut notes = vec![]; + let settled = settle_warped_type(warped, &mut doc, &filled, &BTreeSet::new(), &BTreeSet::new(), &mut vec![], &mut notes); + assert_eq!(settled.is_ok(), ok, "{texts} text objects"); + assert_eq!(notes, ["warped text objects that came in as drawn art, not as editable type: 1"]); + } + } + /// Area type that wrapped around an object taken out of the layers flows without it. #[test] fn wrap_shapes_go_with_their_wrap_object() { diff --git a/crates/eps/src/import/native/slots.rs b/crates/eps/src/import/native/slots.rs index baff41dd8..f0cda3088 100644 --- a/crates/eps/src/import/native/slots.rs +++ b/crates/eps/src/import/native/slots.rs @@ -29,6 +29,7 @@ pub fn is_loss(note: &str) -> bool { note.ends_with(TEXT_LEFT_OUT) || note.ends_with(TYPE_LEFT_OUT) || note.ends_with("they came in as the page's art") + || note.starts_with(ai::WARPED_TYPE_DRAWN) || note == PAGE_TYPE_LEFT_OUT || note == PAGE_TYPE_UNCHECKED || [ART_LEFT_OUT, LAYERS_UNREAD, DREW_DIFFERENTLY, crate::import::graphics::TOO_MUCH, crate::import::graphics::FAR_AWAY] diff --git a/crates/eps/src/import/tests_native.rs b/crates/eps/src/import/tests_native.rs index 6f21d060b..a2d6ec2af 100644 --- a/crates/eps/src/import/tests_native.rs +++ b/crates/eps/src/import/tests_native.rs @@ -1961,6 +1961,94 @@ fn the_later_pages_of_a_pdf_of_some_artboards_are_not_compared() { assert_eq!(d.artboards.len(), 4, "{w:?}"); } +/// The editing data of four 100-point artboards side by side, 50 apart (art space, y up), with a +/// 4-point square on each at (`squares[i]`, 10 + 20 × i), and a text object with no story in the +/// file. +fn four_boards_with_type(squares: [u32; 4]) -> String { + let board = |x0: u32| { + format!( + "%_/Dictionary :\n%_{x0} 100 /RealPointRelToROrigin\n%_ (PositionPoint1) ,\n%_{} 0 /RealPointRelToROrigin\n%_ (PositionPoint2) ,\n%_; ,\n", + x0 + 100 + ) + }; + let boards = (0..4u32).map(|i| board(i * 150)).collect::(); + let boards = format!("%_/Document :\n%_/Dictionary :\n%_/Array :\n{boards}%_; (ArtboardArray) ,\n%_; /NotRecorded ,\n%_;\n"); + let art: Vec = (0..4u32).map(|i| square(i * 150 + squares[i as usize], 10 + 20 * i)).collect(); + native(&(boards + &layer("Art", true, &(art.join("\n") + "\n" + &text_object(0))))) +} + +/// The PDF part of a file with [`four_boards_with_type`] saved with two of its artboards: a page +/// for each in a row, page `i` with a square `squares[i].0` from its left and `squares[i].1` up +/// from its bottom, and "Hi" 30 from the second page's left and 20 down. +fn two_pages_with_type(squares: [(f64, u32); 2]) -> Document { + let at = |(x, y): (f64, u32), page: f64| pdf_part(page + x, 100.0 - f64::from(y) - 4.0); + let mut pdf = at(squares[0], 0.0); + let mut second = pdf.artboards[0].clone(); + (second.id, second.rect) = (2, vectorcraft_geom::Rect::new(120.0, 0.0, 220.0, 100.0)); + pdf.artboards.push(second); + let other = at(squares[1], 120.0); + let square = pdf.reid(&other.layers[0].children().unwrap()[0]); + let layer_id = pdf.layers[0].id; + pdf.insert(Some(layer_id), 1, square).unwrap(); + let style = vectorcraft_doc::CharStyle { size: 14.0, ..Default::default() }; + let text = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(150.0, 20.0), "Hi", style); + let id = pdf.alloc_id(); + pdf.insert(Some(layer_id), 2, Node::new(id, NodeKind::Text(Box::new(text)))).unwrap(); + pdf +} + +/// A PDF saved with artboards that don't follow one another has a page for each: the type on a +/// later page goes to the artboard that page shows, not to the one after the previous page's. +#[test] +fn type_on_a_later_page_of_a_pdf_of_some_artboards_goes_to_its_artboard() { + // The squares 10, 20, 30 and 40 from each artboard's left. + let art = four_boards_with_type([10, 20, 30, 40]); + // Pages for the second and fourth artboards. The fourth artboard starts at x 450, so the type + // 30 from its left is at x 480. + let (d, w, read) = crate::layered_ai(&private(&art), two_pages_with_type([(20.0, 30), (40.0, 70)]), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(d.artboards.len(), 4, "{w:?}"); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 480.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the fourth artboard: {:?}", texts[0].xf); +} + +/// A PDF saved with the first and third of four artboards: the type on its second page goes to +/// the third artboard. +#[test] +fn type_on_the_second_page_of_a_pdf_of_the_first_and_third_artboards_goes_to_the_third() { + let art = four_boards_with_type([10, 20, 30, 40]); + // The third artboard starts at x 300, so the type 30 from its left is at x 330. + let (d, w, read) = crate::layered_ai(&private(&art), two_pages_with_type([(10.0, 10), (30.0, 50)]), vec![], false, false, 1.0); + assert!(read, "{w:?}"); + assert_eq!(d.artboards.len(), 4, "{w:?}"); + let texts = text_of(&d.layers[0]); + assert_eq!(texts.iter().map(|t| t.plain_text()).collect::>(), ["Hi"], "{w:?}"); + let [.., e, f] = texts[0].xf.as_coeffs(); + assert!((e - 330.0).abs() < 1e-6 && (f - 20.0).abs() < 1e-6, "on the third artboard: {:?}", texts[0].xf); +} + +/// An EPS whose page is its art's box: an object the layers can't draw takes the page's art from +/// that box, when the editing data's first artboard has the page's size elsewhere. +#[test] +fn an_unread_object_of_an_eps_of_its_art_box_takes_the_page_art_on_that_box() { + let point = |x: u32, y: u32, n: u8| format!("%_{x} {y} /RealPointRelToROrigin\n%_ (PositionPoint{n}) ,\n"); + let board = |x0: u32| format!("%_/Dictionary :\n{}{}%_; ,\n", point(x0, 100, 1), point(x0 + 100, 0, 2)); + let array = format!("%_/Array :\n{}{}%_; (ArtboardArray) ,\n", board(200), board(0)); + let boards = format!("%_/Document :\n%_/Dictionary :\n{array}%_; /NotRecorded ,\n%_;\n"); + let symbol = format!("%AI14_BeginSymbol\n(Dot) 0 Ae\n{}\n%AI10_EndSymbol\n", square(0, 0)); + let instance = "/SymbolInstance :\n(Dot) /SymbolRef ,\n[1 0 0 1 50 10] /Matrix ,\n;"; + let layers = layer("Live", true, &square(10, 10)) + &layer("Mixed", true, &format!("{instance}\n{}", square(70, 10))); + let art = native(&format!("{boards}{symbol}{layers}")); + let cyan = page_square(50, 10).replace("0 0 1 0 setcmykcolor", "1 0 0 0 setcmykcolor"); + let page = format!("{}\n{cyan}\n{}", page_square(10, 10), page_square(70, 10)); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(names(&r.document), ["Live", "Mixed"], "{:?}", r.warnings); + assert_eq!(kinds(&r.document.layers[1]), ["group[1]", "path"], "{:?}", r.warnings); + replacement_is_cyan(&r.document, 1, 0); +} + /// Layers that draw each page's art on its artboard come in. #[test] fn layers_that_match_every_page_come_in() { @@ -4706,6 +4794,334 @@ fn type_with_a_stacked_art_style_comes_in_as_type_with_that_appearance() { check(&r.document, &r.warnings); } +/// Where [`warped_style`] puts its Warp. +#[derive(Clone, Copy)] +enum WarpOn { + Object, + Fill, + Stroke, +} + +/// An art style section with style `Warped`: a red fill with Effect › Warp › Arc at `bend` percent on +/// `on`. +fn warped_style(bend: f64, on: WarpOn) -> String { + let basic = |name: &str, body: &str| { + format!("/BasicFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n/Dictionary : /NotRecorded ,\n{body}; /Dict ;\n /Part ,\n") + }; + let compound = |name: &str, kids: &[String]| format!("/CompoundFilter :\n({name}) 0 0 /Filter ,\n1 /Visible ,\n{};\n /Part ,\n", kids.concat()); + let fill = basic("Fill Style Filter", "/FillStyle : 0 O\n0 1 1 0 1 0 0 Xa\n0 1 0 0 0 Xy\n0 1 Xd\n/Def ;\n (FillStyle) ,\n"); + let warp = basic( + "Adobe Deform", + &format!( + "1 /Int (DeformStyle) ,\n{} /Real (DeformValue) ,\n0 /Real (DeformHoriz) ,\n0 /Real (DeformVert) ,\n0 /Bool (Rotate) ,\n", + bend / 100.0 + ), + ); + let stroke = || { + basic("Stroke Style Filter", "/StrokeStyle : 0 1 0 0 0 Xy\n0 J 0 j 2 w 10 M []0 d0 XR\n0 0 0 1 0 0 0 XA\n1 0 Xd\n/Def ;\n (StrokeStyle) ,\n") + }; + let conduit = || basic("Conduit Filter", ""); + let chain = match on { + WarpOn::Object => compound("Chain Style Filter", &[fill, warp]), + WarpOn::Fill => { + compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[warp, fill]), conduit()])]) + } + WarpOn::Stroke => { + compound("Chain Style Filter", &[compound("Stack Style Filter", &[compound("Chain Style Filter", &[warp, stroke()]), fill, conduit()])]) + } + }; + format!("%AI9_BeginArtStyles\n/KnownStyle :\n(Warped) /Name ,\n/ActiveStyle :\n{chain}%AI9_EndArtStyles\n") +} + +/// `outline`, a path in the document's coordinates, as filled paths in a 100 × 100 file's art space, +/// in the red of [`warped_style`]. +fn drawn_as_operators(outline: &kurbo::BezPath) -> String { + use kurbo::PathEl; + let mut out = String::from("0 1 1 0 1 0 0 Xa\n"); + let y = |v: f64| 100.0 - v; + let mut last = kurbo::Point::ZERO; + for el in outline.elements() { + match *el { + PathEl::MoveTo(p) => out += &format!("{} {} m\n", p.x, y(p.y)), + PathEl::LineTo(p) => out += &format!("{} {} L\n", p.x, y(p.y)), + PathEl::QuadTo(c, p) => { + let (a, b) = (last + (c - last) * (2.0 / 3.0), p + (c - p) * (2.0 / 3.0)); + out += &format!("{} {} {} {} {} {} C\n", a.x, y(a.y), b.x, y(b.y), p.x, y(p.y)); + } + PathEl::CurveTo(a, b, p) => out += &format!("{} {} {} {} {} {} C\n", a.x, y(a.y), b.x, y(b.y), p.x, y(p.y)), + PathEl::ClosePath => out += "f\n", + } + last = match *el { + PathEl::MoveTo(p) | PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => p, + PathEl::ClosePath => last, + }; + } + out +} + +/// What `n` holds as art, its paths joined, in the document's coordinates. +fn outlines_of(n: &Node) -> kurbo::BezPath { + let mut out = kurbo::BezPath::new(); + if let NodeKind::Path { path, .. } = &n.kind { + out.extend(path.to_bezpath()); + } + for c in n.children().into_iter().flatten() { + out.extend(outlines_of(c)); + } + out +} + +/// The editing copy of `objects` text objects of `text` under style `style`, each with the look +/// `drawing`. +fn warped_file(style: &str, text: &str, objects: u32, drawing: &str) -> String { + let stories: Vec<_> = (0..objects).map(|_| (text, 0u8, (8200.0, 8180.0), (0.0, 0.0), 0.0)).collect(); + let doc = text_document(&stories); + let art: String = (0..objects) + .map(|n| { + let look = format!("u\n{drawing}\nU\n9 () XW\n"); + let own: String = text_object(n).lines().map(|l| format!("%_{l}\n")).collect(); + format!("{look}{own}1 (Warped) XW\n") + }) + .collect(); + native_with_text(&(style.to_string() + &layer("Art", true, &art)), &doc) +} + +/// The type of `doc`, and whether it has a warp. +fn warped_texts(doc: &Document) -> Vec<(String, bool)> { + let mut out = vec![]; + doc.walk(|n| { + if let NodeKind::Text(t) = &n.kind { + out.push(( + t.plain_text(), + n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).any(|e| e.id.starts_with("warp.")), + )); + } + }); + out +} + +#[test] +fn warped_type_that_draws_as_its_look_stays_live_type() { + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, &plain_warped_look()); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + +/// What the live Arc at 40% draws of the type `TILT` that [`warped_file`] places, as look operators. +fn plain_warped_look() -> String { + let doc = text_document(&[("TILT\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let plain = layer("Art", true, &text_object(0)); + let (d, notes) = crate::ai_alone(&private(&native_with_text(&plain, &doc))).unwrap(); + let mut text = None; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text = Some(n.clone()); + } + }); + let mut text = text.unwrap_or_else(|| panic!("no type: {notes:?}")); + text.appearance = warped_appearance().into(); + drawn_as_operators(&outlines_of(&vectorcraft_effects::expand_outlined(None, &text, None).unwrap())) +} + +/// The appearance that [`warped_style`] at 40% on the object reads as. +fn warped_appearance() -> vectorcraft_doc::Appearance { + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, ""); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let mut out = None; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + out = Some((*n.appearance).clone()); + } + }); + out.unwrap_or_else(|| panic!("no type: {notes:?}")) +} + +#[test] +fn warped_type_that_draws_apart_from_its_look_comes_in_as_the_look() { + // GUARDS: a warp on type opened live with no check, so a live warp that draws another shape from + // the file's moved and resized the type. + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + for on in [WarpOn::Object, WarpOn::Fill, WarpOn::Stroke] { + let art = warped_file(&warped_style(40.0, on), "TILT\r", 2, drawing); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [], "{notes:?}"); + let looks = d.layers[0].children().unwrap(); + assert_eq!(looks.len(), 2, "{looks:#?}"); + assert!(looks.iter().all(|l| matches!(l.kind, NodeKind::Group { .. }) && l.visual_bounds().is_some()), "{looks:#?}"); + // One note, with how many, that counts as a loss. + let noted: Vec<&String> = notes.iter().filter(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)).collect(); + assert_eq!(noted, ["warped text objects that came in as drawn art, not as editable type: 2"], "{notes:?}"); + assert!(crate::is_loss(noted[0])); + } +} + +/// Warped type that a text variable binds stays type with the effect live and keeps its binding, +/// though its live warp draws apart from its look. +#[test] +fn warped_type_bound_to_a_text_variable_stays_bound_type() { + use vectorcraft_testkit::ai::{XmlValue, named, variable_library_data, xml_element, xml_node}; + let attributes = + [("trait", XmlValue::Str("textcontent")), ("varName", XmlValue::Unicode("v_text")), ("docRef", XmlValue::Raw("/XMLUIDREF : (type_x5F_1) ;"))]; + let declared = [xml_node(1, "variable", XmlValue::Str(""), &attributes, &[])]; + let set = xml_element("variableSet", &[("locked", "none")], &[xml_element("variables", &[], &declared)]); + let library = variable_library_data(&xml_element("variableSets", &[], &[set])); + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let art = format!("u\n{drawing}\nU\n9 () XW\n{own}{}1 (Warped) XW\n", named("type_x5F_1")); + let doc = text_document(&[("TILT\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let file = native_with_text(&(library + &warped_style(40.0, WarpOn::Object) + &layer("Art", true, &art)), &doc); + let (d, notes) = crate::ai_alone(&private(&file)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); + let mut text = None; + d.walk(|n| { + if matches!(n.kind, NodeKind::Text(_)) { + text = Some(n.id); + } + }); + assert_eq!(d.variables.objects_of("v_text"), [text.unwrap()], "{notes:?}"); +} + +/// `operators` as the page's content: a red fill of the same paths. +fn page_of(operators: &str) -> String { + let mut page = String::from("1 0 0 setrgbcolor\n"); + for line in operators.lines() { + let (args, op) = line.rsplit_once(' ').unwrap_or(("", line)); + let op = match op { + "m" => "moveto", + "L" => "lineto", + "C" => "curveto", + "f" => "closepath fill", + _ => continue, + }; + page += &format!("{args} {op}\n"); + } + page +} + +/// Warped type that opens as its look is compared with the page again, as other art is. +#[test] +fn warped_type_that_comes_in_as_its_look_is_held_to_the_page() { + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"; + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, drawing); + let same = import(&eps(&page_of(drawing), &art)).unwrap(); + assert_eq!(warped_texts(&same.document), [], "{:?}", same.warnings); + assert!(same.warnings.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{:?}", same.warnings); + assert!(!same.warnings.iter().any(|w| w.contains("drew differently")), "{:?}", same.warnings); + // A page that paints only part of the look's box: the box is compared, not left out. + let other = page_of("60 60 m\n90 60 L\n90 75 L\n60 75 L\nf"); + let differs = import(&eps(&other, &art)).map(|r| r.warnings).unwrap_or_else(|why| vec![why.to_string()]); + assert!(differs.iter().any(|w| w.contains("differs from the page") || w.contains("drew differently")), "{differs:?}"); +} + +/// Warped type spends the page's share of the budget one object after another: with enough of them +/// the later ones open as their looks, though each draws close enough on its own. +#[test] +fn warped_type_shares_the_page_budget() { + let look = plain_warped_look(); + let style = warped_style(40.0, WarpOn::Object); + let art = warped_file(&style, "TILT\r", 12, &look); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d).len(), 12, "{notes:?}"); + // The look draws a small square more than the live warp does. + let art = warped_file(&style, "TILT\r", 12, &format!("{look}0 1 1 0 1 0 0 Xa\n2 2 m\n3.5 2 L\n3.5 3.5 L\n2 3.5 L\nf")); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + let live = warped_texts(&d).len(); + assert!(live > 0 && live < 12, "{live}: {notes:?}"); + assert!(notes.iter().any(|w| w == &format!("{}: {}", crate::import::ai::WARPED_TYPE_DRAWN, 12 - live)), "{live}: {notes:?}"); +} + +/// Envelopes and warped type share one count of checks: past it, warped type that would pass opens +/// as its look. +#[test] +fn warped_type_past_the_envelopes_checks_comes_in_as_its_look() { + let hidden = |s: &str| -> String { s.lines().map(|l| format!("%_{l}\n")).collect() }; + let rect = "20 5 m\n20 95 L\n80 95 L\n80 5 L\n20 5 L\n"; + let corner = |x: f64, y: f64, nx: f64, ny: f64, px: f64, py: f64| format!("[0.8 1 {x} {y} {nx} {ny} 1 {px} {py} 1] /N X#\n"); + let mesh = format!( + "/Mesh X!\n2 /Version X#\n/Cartesian /Type X#\n[1 1] /Size X#\n/Data X#\n/DeviceGray /CS X#\n[0 0] /P X#\n{}{}{}{}/E X#\n/End X!\n", + corner(20.0, 5.0, 20.0, 35.0, 40.0, 5.0), + corner(20.0, 95.0, 40.0, 95.0, 20.0, 65.0), + corner(80.0, 95.0, 80.0, 65.0, 60.0, 95.0), + corner(80.0, 5.0, 60.0, 5.0, 80.0, 35.0), + ); + let red = "0 1 1 0 1 0 0 Xa\n"; + let envelope = format!( + "{}%_/ArtDictionary :\n%_131073 /Int (AIDeformOptionsKey) ,\n%_50 /Int (AIDeformFidelityKey) ,\n%_0 /Bool (AIDeformEditModeKey) ,\n%_X=\n{}%_X+\n%_ (AIDeformArtKey) ,\n%_2 /Int (AIDeformStyle) ,\n%_;\n%_\nu\n{red}{rect}f\nU\n(Adobe Deform Plugin) 1 0 0 XP\n", + hidden(&format!("u\n{mesh}U\n")), + hidden(&format!("u\n{red}{rect}f\nU\n")), + ); + let look = format!("u\n{}\nU\n9 () XW\n", plain_warped_look()); + let own: String = text_object(0).lines().map(|l| format!("%_{l}\n")).collect(); + let doc = text_document(&[("TILT\r", 0, (8200.0, 8180.0), (0.0, 0.0), 0.0)]); + let file = |envelopes: usize| { + let body = format!("{}{look}{own}1 (Warped) XW\n", envelope.repeat(envelopes)); + native_with_text(&(warped_style(40.0, WarpOn::Object) + &layer("Art", true, &body)), &doc) + }; + let (d, notes) = crate::ai_alone(&private(&file(1))).unwrap(); + assert_eq!(warped_texts(&d).len(), 1, "{notes:?}"); + let (d, notes) = crate::ai_alone(&private(&file(crate::import::ai::MAX_CHECKED))).unwrap(); + assert_eq!(warped_texts(&d), [], "{notes:?}"); + assert!(notes.iter().any(|w| w == &format!("{}: 1", crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + +#[test] +fn warped_type_with_a_look_that_draws_nothing_stays_type() { + // The look only held the type, which the slot's text object replaces, or a path that paints + // nothing: there is no art to stand in. + for drawing in ["", LOOK_TYPE, "54 56 m\n100 56 L\n100 80 L\n54 80 L\nn"] { + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, drawing); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); + } +} + +/// A look that draws type as well as art is missing the type once the slot's text object takes it, +/// so it can't stand in: the object stays type, and its letters stay on the page. +#[test] +fn warped_type_whose_look_also_draws_type_stays_type() { + let drawing = format!("{LOOK_TYPE}0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"); + let art = warped_file(&warped_style(40.0, WarpOn::Fill), "TILT\r", 1, &drawing); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(warped_texts(&d), [("TILT".to_string(), true)], "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + +/// A look with an object that wasn't read makes the file open from its page: the look isn't used. +#[test] +fn warped_type_whose_look_holds_an_unread_object_opens_from_the_page() { + let drawing = "0 1 1 0 1 0 0 Xa\n54 56 m\n100 56 L\n100 80 L\n54 80 L\nf\nfrobnicate"; + let art = warped_file(&warped_style(40.0, WarpOn::Object), "TILT\r", 1, drawing); + let page = page_of("54 56 m\n100 56 L\n100 80 L\n54 80 L\nf"); + let r = import(&eps(&page, &art)).unwrap(); + assert_eq!(warped_texts(&r.document), [], "{:?}", r.warnings); + assert!(!r.warnings.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{:?}", r.warnings); + assert!(r.warnings.iter().any(|w| w.contains("weren't read")), "{:?}", r.warnings); +} + +/// Warped frames of threaded type stay type, and stay one thread, whatever their looks draw. +#[test] +fn warped_threaded_type_stays_threaded() { + let frames = [ + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(10.0, 60.0, 40.0, 80.0)), + format!("<< /0 << /0 [ 0 0 ] /1 << /0 [ {} ] >> /2 << /0 1 >> >> >>", canvas_rect(50.0, 60.0, 80.0, 80.0)), + ]; + let doc = document_of(&frames, &[story("A story in two frames\\r", &[0, 1], "/0 0", "/1 10.0")]); + let body: String = (0..2) + .map(|frame| { + let own: String = frame_object(0, frame).lines().map(|l| format!("%_{l}\n")).collect(); + format!("u\n0 1 1 0 1 0 0 Xa\n2 2 m\n9 2 L\n9 9 L\n2 9 L\nf\nU\n9 () XW\n{own}1 (Warped) XW\n") + }) + .collect(); + let art = native_with_text(&(warped_style(40.0, WarpOn::Object) + &layer("Art", true, &body)), &doc); + let (d, notes) = crate::ai_alone(&private(&art)).unwrap(); + assert_eq!(d.text_threads.len(), 1, "{notes:?}"); + assert_eq!(warped_texts(&d).len(), 2, "{notes:?}"); + assert!(!notes.iter().any(|w| w.starts_with(crate::import::ai::WARPED_TYPE_DRAWN)), "{notes:?}"); +} + /// An art style section with style `Add`: Effect › Pathfinder › Add in the object's chain, over a /// fill and stroke that paint nothing. fn pathfinder_add_style() -> String { diff --git a/crates/eps/tests/envelope_ink.rs b/crates/eps/tests/envelope_ink.rs new file mode 100644 index 000000000..eccbf8a8f --- /dev/null +++ b/crates/eps/tests/envelope_ink.rs @@ -0,0 +1,30 @@ +//! Reading an `.ai` file makes envelopes measure type by its glyph outlines, as the app does. One +//! test in its own process, so nothing else has registered the measure before the file is read. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect}; + +/// The largest distance between the edges of `a` and `b`. +fn off(a: Rect, b: Rect) -> f64 { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())) +} + +#[test] +fn reading_an_ai_file_measures_type_in_envelopes_by_its_glyphs() { + let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() }); + let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + let laid = n.geometric_bounds().unwrap(); + let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap(); + assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}"); + + let file = "%!PS-Adobe-3.0\n%%Creator: test\n%AI5_FileFormat 3.0\n%AI3_Cropmarks: 0 0 200 100\n%%EndComments\n%%BeginProlog\n%%EndProlog\n%%BeginSetup\n%%EndSetup\n%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\nLB\n%AI5_EndLayer--\n%%PageTrailer\ngsave annotatepage grestore showpage\n%%Trailer\n%%EOF\n"; + vectorcraft_eps::import(file.as_bytes()).unwrap(); + let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap(); + assert!(off(after, ink) < 1e-6, "after reading, type is measured by its glyphs: {after:?} vs {ink:?}"); +} diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml new file mode 100644 index 000000000..0108b50d9 --- /dev/null +++ b/crates/js/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "vectorcraft-js" +description = "JavaScript for scripts: an ECMAScript 5.1 lexer and parser to a syntax tree with source positions" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +unicode-general-category = "1" + +[dev-dependencies] +proptest = { workspace = true } diff --git a/crates/js/src/ast.rs b/crates/js/src/ast.rs new file mode 100644 index 000000000..f0dd6b44b --- /dev/null +++ b/crates/js/src/ast.rs @@ -0,0 +1,334 @@ +//! The syntax tree: expressions, statements and functions kept in arenas ([`Ast`]) and referred to +//! by index, each with its source span. Nodes hold indices rather than boxes, so dropping a deeply +//! nested tree does not recurse. + +use crate::token::Span; + +/// An expression's index in [`Ast::exprs`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ExprId(pub u32); + +/// A statement's index in [`Ast::stmts`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct StmtId(pub u32); + +/// A function's index in [`Ast::functions`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct FunctionId(pub u32); + +/// An identifier as written, with its span. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Name { + pub name: String, + pub span: Span, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnaryOp { + Delete, + Void, + Typeof, + Plus, + Minus, + BitNot, + Not, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UpdateOp { + Increment, + Decrement, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BinaryOp { + Mul, + Div, + Mod, + Add, + Sub, + Shl, + Shr, + UShr, + Lt, + Gt, + LtEq, + GtEq, + Instanceof, + In, + Eq, + NotEq, + StrictEq, + StrictNotEq, + BitAnd, + BitXor, + BitOr, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogicalOp { + And, + Or, +} + +/// `=` (`None`) or a compound assignment's operator (`+=` is `Some(BinaryOp::Add)`). +pub type AssignOp = Option; + +/// A property name in an object literal. +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyKey { + Ident(String), + String(Vec), + Number(f64), +} + +impl PropertyKey { + /// The key as the string a property is stored under (a number through ToString). + pub fn to_utf16(&self) -> Vec { + match self { + PropertyKey::Ident(name) => name.encode_utf16().collect(), + PropertyKey::String(s) => s.clone(), + PropertyKey::Number(n) => crate::number::to_string(*n).encode_utf16().collect(), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PropertyValue { + Init(ExprId), + Get(FunctionId), + Set(FunctionId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Property { + pub key: PropertyKey, + pub value: PropertyValue, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Expr { + This, + Ident(String), + Null, + Bool(bool), + Number(f64), + String(Vec), + RegExp { + pattern: Vec, + flags: String, + }, + /// An array literal; `None` is a hole (`[1, , 3]`). + Array(Vec>), + Object(Vec), + Function(FunctionId), + /// `object.property`. + Member { + object: ExprId, + property: Name, + }, + /// `object[index]`. + Index { + object: ExprId, + index: ExprId, + }, + Call { + callee: ExprId, + args: Vec, + }, + New { + callee: ExprId, + args: Vec, + }, + Update { + op: UpdateOp, + prefix: bool, + target: ExprId, + }, + Unary { + op: UnaryOp, + arg: ExprId, + }, + Binary { + op: BinaryOp, + left: ExprId, + right: ExprId, + }, + Logical { + op: LogicalOp, + left: ExprId, + right: ExprId, + }, + Conditional { + test: ExprId, + consequent: ExprId, + alternate: ExprId, + }, + Assign { + op: AssignOp, + target: ExprId, + value: ExprId, + }, + /// Comma-separated expressions. + Sequence(Vec), + /// A parenthesized expression, kept so a rule about the bare form (a label, a directive) can + /// tell them apart. + Paren(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct VarDecl { + pub name: Name, + pub init: Option, +} + +/// What a `for (… in …)` statement assigns each property name to. +#[derive(Clone, Debug, PartialEq)] +pub enum ForInTarget { + /// `for (var x in o)`, with the declaration's initializer if it has one. + Var(VarDecl), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub enum ForInit { + Var(Vec), + Expr(ExprId), +} + +#[derive(Clone, Debug, PartialEq)] +pub struct SwitchCase { + /// `None` for `default`. + pub test: Option, + pub body: Vec, + pub span: Span, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct CatchClause { + pub param: Name, + pub body: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Stmt { + Var(Vec), + FunctionDecl(FunctionId), + Expr(ExprId), + Block(Vec), + Empty, + If { test: ExprId, consequent: StmtId, alternate: Option }, + DoWhile { body: StmtId, test: ExprId }, + While { test: ExprId, body: StmtId }, + For { init: Option, test: Option, update: Option, body: StmtId }, + ForIn { target: ForInTarget, object: ExprId, body: StmtId }, + Continue(Option), + Break(Option), + Return(Option), + With { object: ExprId, body: StmtId }, + Switch { discriminant: ExprId, cases: Vec }, + Labeled { label: Name, body: StmtId }, + Throw(ExprId), + Try { block: Vec, handler: Option, finalizer: Option> }, + Debugger, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FunctionKind { + Declaration, + Expression, + Getter, + Setter, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Function { + pub kind: FunctionKind, + pub name: Option, + pub params: Vec, + pub body: Vec, + /// The function's code is strict mode code: its own directive prologue holds `"use strict"`, + /// or the code around it is strict. + pub strict: bool, + pub span: Span, +} + +/// The arenas of a parsed script. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Ast { + pub exprs: Vec<(Expr, Span)>, + pub stmts: Vec<(Stmt, Span)>, + pub functions: Vec, +} + +impl Ast { + pub fn expr(&self, id: ExprId) -> Option<&Expr> { + self.exprs.get(id.0 as usize).map(|(e, _)| e) + } + + pub fn expr_span(&self, id: ExprId) -> Span { + self.exprs.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default() + } + + pub fn stmt(&self, id: StmtId) -> Option<&Stmt> { + self.stmts.get(id.0 as usize).map(|(s, _)| s) + } + + pub fn stmt_span(&self, id: StmtId) -> Span { + self.stmts.get(id.0 as usize).map(|(_, s)| *s).unwrap_or_default() + } + + pub fn function(&self, id: FunctionId) -> Option<&Function> { + self.functions.get(id.0 as usize) + } + + /// The expression with any parentheses around it removed. + pub fn unparenthesized(&self, mut id: ExprId) -> ExprId { + while let Some(Expr::Paren(inner)) = self.expr(id) { + id = *inner; + } + id + } + + pub(crate) fn push_expr(&mut self, expr: Expr, span: Span) -> ExprId { + self.exprs.push((expr, span)); + ExprId(u32::try_from(self.exprs.len() - 1).unwrap_or(u32::MAX)) + } + + pub(crate) fn push_stmt(&mut self, stmt: Stmt, span: Span) -> StmtId { + self.stmts.push((stmt, span)); + StmtId(u32::try_from(self.stmts.len() - 1).unwrap_or(u32::MAX)) + } + + pub(crate) fn push_function(&mut self, function: Function) -> FunctionId { + self.functions.push(function); + FunctionId(u32::try_from(self.functions.len() - 1).unwrap_or(u32::MAX)) + } +} + +/// A parsed script. +#[derive(Clone, Debug, PartialEq)] +pub struct Program { + pub ast: Ast, + pub body: Vec, + /// The script is strict mode code (its directive prologue holds `"use strict"`, or it was + /// parsed with [`crate::Options::strict`]). + pub strict: bool, + /// Its preprocessor lines, in source order. + pub directives: Vec, + pub(crate) lines: crate::LineTable, +} + +impl Program { + /// The line and column (both from 1, the column in UTF-16 code units) of a source offset. + pub fn line_column(&self, offset: u32) -> (u32, u32) { + self.lines.line_column(offset) + } + + /// The number of lines in the source: its line terminators plus one for a last line without + /// one. + pub fn line_count(&self) -> u32 { + self.lines.line_count() + } +} diff --git a/crates/js/src/chars.rs b/crates/js/src/chars.rs new file mode 100644 index 000000000..75c0b92aa --- /dev/null +++ b/crates/js/src/chars.rs @@ -0,0 +1,67 @@ +//! Character classes of ECMA-262 5.1 section 7: white space, line terminators and the characters +//! identifiers are made of. + +use unicode_general_category::{GeneralCategory as G, get_general_category}; + +/// A line terminator: LF, CR, U+2028 or U+2029 (section 7.3). +pub fn is_line_terminator(c: u32) -> bool { + matches!(c, 0x0A | 0x0D | 0x2028 | 0x2029) +} + +/// White space (section 7.2): tab, vertical tab, form feed, space, no-break space, the byte order +/// mark and the other space separators (Unicode category Zs). +pub fn is_whitespace(c: u32) -> bool { + matches!(c, 0x09 | 0x0B | 0x0C | 0x20 | 0xA0 | 0xFEFF | 0x1680 | 0x2000..=0x200A | 0x202F | 0x205F | 0x3000) +} + +/// A character an identifier may start with: a letter (categories Lu, Ll, Lt, Lm, Lo and Nl), +/// `$` or `_` (section 7.6). `\u` escapes are decoded before this check. +pub fn is_id_start(c: u32) -> bool { + match c { + 0x24 | 0x5F => true, + 0x41..=0x5A | 0x61..=0x7A => true, + 0..=0x7F => false, + // U+2E2F VERTICAL TILDE is a letter (Lm) that Unicode leaves out of identifiers. + 0x2E2F => false, + _ => category(c).is_some_and(|g| { + matches!(g, G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber) + }), + } +} + +/// A character an identifier may hold after its first: those it may start with, decimal digits +/// (Nd), combining marks (Mn, Mc), connector punctuation (Pc), and the zero width non-joiner and +/// joiner. +pub fn is_id_part(c: u32) -> bool { + match c { + 0x30..=0x39 => true, + 0x200C | 0x200D => true, + 0x2E2F => false, + _ if is_id_start(c) => true, + 0..=0x7F => false, + _ => category(c).is_some_and(|g| matches!(g, G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation)), + } +} + +/// The Unicode general category of a code point; `None` for a lone surrogate. +fn category(c: u32) -> Option { + char::from_u32(c).map(get_general_category) +} + +/// The value of a hexadecimal digit. +pub fn hex_value(c: u32) -> Option { + match c { + 0x30..=0x39 => Some(c - 0x30), + 0x41..=0x46 => Some(c - 0x41 + 10), + 0x61..=0x66 => Some(c - 0x61 + 10), + _ => None, + } +} + +pub fn is_decimal_digit(c: u32) -> bool { + (0x30..=0x39).contains(&c) +} + +pub fn is_octal_digit(c: u32) -> bool { + (0x30..=0x37).contains(&c) +} diff --git a/crates/js/src/error.rs b/crates/js/src/error.rs new file mode 100644 index 000000000..9c17bf8be --- /dev/null +++ b/crates/js/src/error.rs @@ -0,0 +1,29 @@ +//! The error a script that cannot be read gives: a `SyntaxError` with its message and where in the +//! source it is. + +use std::fmt; + +/// A script that does not follow the grammar, or breaks one of its early-error rules. `line` and +/// `column` count from 1; the column counts UTF-16 code units from the start of the line, as +/// JavaScript source positions do. `offset` is the UTF-16 offset from the start of the source. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SyntaxError { + pub message: String, + pub line: u32, + pub column: u32, + pub offset: u32, +} + +impl fmt::Display for SyntaxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "SyntaxError: {} (line {}, column {})", self.message, self.line, self.column) + } +} + +impl std::error::Error for SyntaxError {} + +/// The message of the error nesting deeper than [`crate::Options::max_depth`] gives. +pub const TOO_DEEP: &str = "nesting too deep"; + +/// The message of the error a source longer than [`crate::Options::max_source_units`] gives. +pub const TOO_LONG: &str = "source too long"; diff --git a/crates/js/src/lexer.rs b/crates/js/src/lexer.rs new file mode 100644 index 000000000..9b16b16c7 --- /dev/null +++ b/crates/js/src/lexer.rs @@ -0,0 +1,637 @@ +//! The lexer (ECMA-262 5.1 section 7): source text in UTF-16 code units into tokens, with each +//! token's line and column and whether a line terminator comes before it. +//! +//! A `/` starts a division punctuator or a regular expression literal depending on where the +//! grammar is (section 7, the InputElementDiv and InputElementRegExp goals). The lexer reads a +//! token for the goal it is given; the parser reads tokens for the division goal and asks for a +//! token to be read again as a regular expression where an expression starts +//! ([`Lexer::rescan_regexp`]). +//! +//! With [`crate::Options::preprocessor_lines`], a `#name value` line, or a `//@name value` comment +//! that names a directive, at the start of a line (only blanks before it) gives a +//! [`TokenKind::Directive`] token. +//! +//! The lexer moves forward through the source one character at a time and never recurses. + +use crate::chars::{hex_value, is_decimal_digit, is_id_part, is_id_start, is_line_terminator, is_octal_digit, is_whitespace}; +use crate::error::SyntaxError; +use crate::regexp; +use crate::token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; + +/// Which goal symbol the next token is read for: whether a `/` there divides or starts a regular +/// expression literal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Goal { + Div, + RegExp, +} + +const BACKSLASH: u32 = b'\\' as u32; +const SLASH: u32 = b'/' as u32; +const STAR: u32 = b'*' as u32; +const DOT: u32 = b'.' as u32; +const CR: u32 = 0x0D; +const LF: u32 = 0x0A; + +/// The lexer's state. It is small and cheap to clone, which is how the parser looks one token +/// ahead. +#[derive(Clone, Debug)] +pub struct Lexer<'s> { + src: &'s [u16], + pos: usize, + /// The current line (from 1) and the offset where it starts. + line: u32, + line_start: usize, + /// Only blanks since the start of the line: a preprocessor line may start here. + blank_line: bool, + preprocessor_lines: bool, + /// The deepest nesting of groups a regular expression literal may have. + max_depth: usize, +} + +impl<'s> Lexer<'s> { + /// A lexer of `src`. `preprocessor_lines` turns on the reading of preprocessor lines; + /// `max_depth` limits how deeply a regular expression literal's groups may nest. + pub fn new(src: &'s [u16], preprocessor_lines: bool, max_depth: usize) -> Self { + Self { src, pos: 0, line: 1, line_start: 0, blank_line: true, preprocessor_lines, max_depth } + } + + /// The code unit at `pos`, as a code point value. + fn unit(&self, pos: usize) -> Option { + self.src.get(pos).map(|&u| u32::from(u)) + } + + fn peek(&self) -> Option { + self.unit(self.pos) + } + + fn peek_at(&self, n: usize) -> Option { + self.unit(self.pos + n) + } + + /// The code point at `pos` (a surrogate pair read as one) and how many units it takes. + fn code_point(&self, pos: usize) -> Option<(u32, usize)> { + let hi = self.unit(pos)?; + if (0xD800..0xDC00).contains(&hi) + && let Some(lo) = self.unit(pos + 1).filter(|lo| (0xDC00..0xE000).contains(lo)) + { + return Some((0x10000 + ((hi - 0xD800) << 10) + (lo - 0xDC00), 2)); + } + Some((hi, 1)) + } + + fn column_of(&self, pos: usize) -> u32 { + u32::try_from(pos.saturating_sub(self.line_start) + 1).unwrap_or(u32::MAX) + } + + fn offset(pos: usize) -> u32 { + u32::try_from(pos).unwrap_or(u32::MAX) + } + + /// An error at `pos` on the current line. + fn error_at(&self, pos: usize, message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: self.line, column: self.column_of(pos), offset: Self::offset(pos) } + } + + /// An error at a position recorded earlier. + fn error_from(start: (usize, u32, u32), message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: start.1, column: start.2, offset: Self::offset(start.0) } + } + + /// Reads a line terminator at `pos` (CR LF as one), counting the line. False when there is + /// none. + fn line_terminator(&mut self) -> bool { + match self.peek() { + Some(CR) if self.peek_at(1) == Some(LF) => self.pos += 2, + Some(c) if is_line_terminator(c) => self.pos += 1, + _ => return false, + } + self.line = self.line.saturating_add(1); + self.line_start = self.pos; + self.blank_line = true; + true + } + + /// The next token, read for `goal`. + pub fn next_token(&mut self, goal: Goal) -> Result { + let newline_before = match self.skip_trivia()? { + Trivia::Directive(token) => return Ok(*token), + Trivia::Skipped { newline } => newline, + }; + let start = self.pos; + let (line, column) = (self.line, self.column_of(start)); + let Some(c) = self.peek() else { + return Ok(self.token(TokenKind::Eof, start, line, column, newline_before)); + }; + self.blank_line = false; + let mut token = if c == BACKSLASH || is_id_start(c) || self.code_point(start).is_some_and(|(cp, n)| n == 2 && is_id_start(cp)) { + self.identifier(start, line, column)? + } else if is_decimal_digit(c) || (c == DOT && self.peek_at(1).is_some_and(is_decimal_digit)) { + self.number(start, line, column)? + } else if c == u32::from(b'"') || c == u32::from(b'\'') { + self.string(start, line, column)? + } else if c == SLASH && goal == Goal::RegExp { + self.regexp(start, line, column)? + } else { + let punct = self.punct().ok_or_else(|| self.error_at(start, unexpected_character(c)))?; + self.token(TokenKind::Punct(punct), start, line, column, false) + }; + // A string continued over a line ends on a new line, but not at its start. + self.blank_line = false; + token.newline_before = newline_before; + Ok(token) + } + + /// Reads the token `token` again as a regular expression literal: the parser read a `/` or + /// `/=` where an expression starts. + pub fn rescan_regexp(&mut self, token: &Token) -> Result { + let start = usize::try_from(token.span.start).unwrap_or(usize::MAX); + self.pos = start; + self.line = token.line; + self.line_start = start.saturating_sub(usize::try_from(token.column.saturating_sub(1)).unwrap_or(0)); + self.blank_line = false; + let mut regexp = self.regexp(start, token.line, token.column)?; + regexp.newline_before = token.newline_before; + Ok(regexp) + } + + fn token(&self, kind: TokenKind, start: usize, line: u32, column: u32, newline_before: bool) -> Token { + Token { + kind, + span: Span::new(Self::offset(start), Self::offset(self.pos)), + line, + column, + newline_before, + escaped: false, + legacy_octal: false, + } + } + + /// Skips white space, line terminators and comments, and reads a preprocessor line when one + /// starts here. + fn skip_trivia(&mut self) -> Result { + let mut newline = false; + while let Some(c) = self.peek() { + if is_whitespace(c) { + self.pos += 1; + } else if self.line_terminator() { + newline = true; + } else if c == SLASH && self.peek_at(1) == Some(SLASH) { + if self.blank_line && self.preprocessor_lines && self.names_directive(self.pos + 2) { + return Ok(Trivia::Directive(Box::new(self.directive("//@", newline)))); + } + while self.peek().is_some_and(|c| !is_line_terminator(c)) { + self.pos += 1; + } + } else if c == SLASH && self.peek_at(1) == Some(STAR) { + newline |= self.block_comment()?; + // The comment's end is on this line, so a `#` after it is not at the line's start. + self.blank_line = false; + } else if c == u32::from(b'#') && self.blank_line && self.preprocessor_lines && self.peek_at(1).is_some_and(is_name_char) { + return Ok(Trivia::Directive(Box::new(self.directive("#", newline)))); + } else { + break; + } + } + Ok(Trivia::Skipped { newline }) + } + + /// Skips a `/* */` comment; true when it holds a line terminator. + fn block_comment(&mut self) -> Result { + let start = (self.pos, self.line, self.column_of(self.pos)); + self.pos += 2; + let mut newline = false; + loop { + match self.peek() { + None => return Err(Self::error_from(start, "unterminated comment")), + Some(STAR) if self.peek_at(1) == Some(SLASH) => { + self.pos += 2; + return Ok(newline); + } + Some(_) if self.line_terminator() => newline = true, + Some(_) => self.pos += 1, + } + } + } + + /// `//@` at `pos - 2` is followed by a directive's name. + fn names_directive(&self, pos: usize) -> bool { + if self.unit(pos) != Some(u32::from(b'@')) { + return false; + } + let name: String = self.src[pos + 1..].iter().take_while(|&&u| is_name_char(u32::from(u))).map(|&u| char::from(u as u8)).collect(); + DirectiveName::from_name(&name).is_some() + } + + /// Reads a preprocessor line written with `form` (`#` or `//@`), up to the end of the line. + fn directive(&mut self, form: &'static str, newline_before: bool) -> Token { + let start = self.pos; + let (line, column) = (self.line, self.column_of(start)); + self.pos += form.encode_utf16().count(); + let name_start = self.pos; + while self.peek().is_some_and(is_name_char) { + self.pos += 1; + } + let name = String::from_utf16_lossy(&self.src[name_start..self.pos]); + let value_start = self.pos; + while self.peek().is_some_and(|c| !is_line_terminator(c)) { + self.pos += 1; + } + let value = String::from_utf16_lossy(&self.src[value_start..self.pos]).trim().to_string(); + let directive = Directive { + known: DirectiveName::from_name(&name), + name, + value, + form, + span: Span::new(Self::offset(start), Self::offset(self.pos)), + line, + }; + self.blank_line = false; + self.token(TokenKind::Directive(directive), start, line, column, newline_before) + } + + /// Reads an identifier or a keyword (section 7.6), `\u` escapes included. + fn identifier(&mut self, start: usize, line: u32, column: u32) -> Result { + let mut name = String::new(); + let mut escaped = false; + let mut first = true; + loop { + let at = self.pos; + let (c, len, is_escape) = match self.code_point(at) { + Some((BACKSLASH, _)) => { + let c = self.identifier_escape()?; + (c, 0, true) + } + Some((c, len)) => (c, len, false), + None => break, + }; + let ok = if first { is_id_start(c) } else { is_id_part(c) }; + if !ok { + if is_escape { + return Err(self.error_at(at, "invalid character in identifier escape")); + } + break; + } + self.pos += len; + escaped |= is_escape; + first = false; + match char::from_u32(c) { + Some(ch) => name.push(ch), + None => return Err(self.error_at(at, "invalid character in identifier")), + } + } + let kind = match Keyword::from_word(&name) { + Some(k) if !escaped => TokenKind::Keyword(k), + _ => TokenKind::Ident(name), + }; + let mut token = self.token(kind, start, line, column, false); + token.escaped = escaped; + Ok(token) + } + + /// Reads a `\uXXXX` escape in an identifier and returns its code point. + fn identifier_escape(&mut self) -> Result { + let at = self.pos; + if self.peek_at(1) != Some(u32::from(b'u')) { + return Err(self.error_at(at, "invalid escape in identifier")); + } + self.pos += 2; + self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence")) + } + + /// After `\u`: four hexadecimal digits, or a code point in braces (`\u{1F600}`, ECMA-262 2015 + /// section 11.8.4) up to U+10FFFF. + fn unicode_escape(&mut self) -> Option { + if self.peek() != Some(u32::from(b'{')) { + return self.hex_digits(4); + } + let mut n = 1; + let mut value: u32 = 0; + while let Some(d) = self.peek_at(n).and_then(hex_value) { + value = value.checked_mul(16)?.checked_add(d).filter(|&v| v <= 0x10FFFF)?; + n += 1; + } + if n == 1 || self.peek_at(n) != Some(u32::from(b'}')) { + return None; + } + self.pos += n + 1; + Some(value) + } + + /// Reads `n` hexadecimal digits and returns their value, or `None` (leaving the position) when + /// fewer follow. + fn hex_digits(&mut self, n: usize) -> Option { + let mut value = 0; + for i in 0..n { + value = value * 16 + hex_value(self.peek_at(i)?)?; + } + self.pos += n; + Some(value) + } + + /// Reads a numeric literal (section 7.8.3, with the legacy octal literals of Annex B). + fn number(&mut self, start: usize, line: u32, column: u32) -> Result { + let mut legacy_octal = false; + let value = if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(|c| c == u32::from(b'x') || c == u32::from(b'X')) { + self.pos += 2; + self.radix_digits(16).ok_or_else(|| self.error_at(start, "hexadecimal literal without digits"))? + } else if self.peek() == Some(u32::from(b'0')) && self.peek_at(1).is_some_and(is_decimal_digit) { + legacy_octal = true; + self.legacy_octal_or_decimal() + } else { + self.decimal() + }; + if self.peek().is_some_and(|c| c == BACKSLASH || is_id_start(c) || is_decimal_digit(c)) { + return Err(self.error_at(self.pos, "identifier starts immediately after numeric literal")); + } + let mut token = self.token(TokenKind::Number(value), start, line, column, false); + token.legacy_octal = legacy_octal; + Ok(token) + } + + /// Reads digits in `radix` (8 or 16) and returns their value, or `None` when there are none. + fn radix_digits(&mut self, radix: u32) -> Option { + let begin = self.pos; + let mut exact: u128 = 0; + let mut value = 0.0_f64; + let mut overflow = false; + while let Some(d) = self.peek().and_then(hex_value).filter(|&d| d < radix) { + match exact.checked_mul(u128::from(radix)).and_then(|v| v.checked_add(u128::from(d))) { + Some(v) if !overflow => exact = v, + _ => { + if !overflow { + value = exact as f64; + overflow = true; + } + value = value * f64::from(radix) + f64::from(d); + } + } + self.pos += 1; + } + if self.pos == begin { + return None; + } + Some(if overflow { value } else { exact as f64 }) + } + + /// After `0` and a digit: an octal literal when every digit is octal, else a decimal literal + /// whose integer part starts with `0` (`08`, `09.5`). + fn legacy_octal_or_decimal(&mut self) -> f64 { + let begin = self.pos; + let mut end = begin; + while self.unit(end).is_some_and(is_decimal_digit) { + end += 1; + } + if self.src[begin..end].iter().all(|&u| is_octal_digit(u32::from(u))) { + self.pos += 1; + return self.radix_digits(8).unwrap_or(0.0); + } + self.decimal() + } + + /// Reads a decimal literal: digits, an optional fraction and an optional exponent. + fn decimal(&mut self) -> f64 { + let mut text = String::new(); + self.push_digits(&mut text); + if text.is_empty() { + text.push('0'); + } + if self.peek() == Some(DOT) { + self.pos += 1; + text.push('.'); + let before = text.len(); + self.push_digits(&mut text); + if text.len() == before { + text.push('0'); + } + } + if self.peek().is_some_and(|c| c == u32::from(b'e') || c == u32::from(b'E')) { + let sign = self.peek_at(1).filter(|&c| c == u32::from(b'+') || c == u32::from(b'-')); + let digit_at = if sign.is_some() { 2 } else { 1 }; + if self.peek_at(digit_at).is_some_and(is_decimal_digit) { + self.pos += digit_at; + text.push('e'); + if sign == Some(u32::from(b'-')) { + text.push('-'); + } + self.push_digits(&mut text); + } + } + text.parse().unwrap_or(f64::NAN) + } + + fn push_digits(&mut self, text: &mut String) { + while let Some(c) = self.peek().filter(|&c| is_decimal_digit(c)) { + text.push(char::from(c as u8)); + self.pos += 1; + } + } + + /// Reads a string literal (section 7.8.4, with the octal escapes of Annex B) and returns its + /// value. + fn string(&mut self, start: usize, line: u32, column: u32) -> Result { + let quote = self.peek().unwrap_or_default(); + self.pos += 1; + let mut value = Vec::new(); + let mut legacy_octal = false; + loop { + match self.peek() { + None => return Err(Self::error_from((start, line, column), "unterminated string literal")), + // U+2028 and U+2029 may stand in a string, as ECMA-262 2019 allows; they still + // start a new line for positions. + Some(c @ (0x2028 | 0x2029)) => { + value.push(c as u16); + self.line_terminator(); + } + Some(c) if is_line_terminator(c) => { + return Err(Self::error_from((start, line, column), "unterminated string literal")); + } + Some(c) if c == quote => { + self.pos += 1; + break; + } + Some(BACKSLASH) => legacy_octal |= self.string_escape(&mut value)?, + Some(c) => { + value.push(c as u16); + self.pos += 1; + } + } + } + let mut token = self.token(TokenKind::String(value), start, line, column, false); + token.legacy_octal = legacy_octal; + Ok(token) + } + + /// Reads an escape in a string literal into `value`; true for an octal escape (`\1`, `\08`) + /// or `\8` and `\9`. + fn string_escape(&mut self, value: &mut Vec) -> Result { + let at = self.pos; + self.pos += 1; + let Some(c) = self.peek() else { + return Err(self.error_at(at, "unterminated string literal")); + }; + if self.line_terminator() { + return Ok(false); + } + let single = match char::from_u32(c) { + Some('b') => Some(0x08), + Some('t') => Some(0x09), + Some('n') => Some(0x0A), + Some('v') => Some(0x0B), + Some('f') => Some(0x0C), + Some('r') => Some(0x0D), + _ => None, + }; + if let Some(unit) = single { + value.push(unit); + self.pos += 1; + return Ok(false); + } + match char::from_u32(c) { + Some('x') => { + self.pos += 1; + let unit = self.hex_digits(2).ok_or_else(|| self.error_at(at, "invalid hexadecimal escape sequence"))?; + value.push(unit as u16); + Ok(false) + } + Some('u') => { + self.pos += 1; + let code_point = self.unicode_escape().ok_or_else(|| self.error_at(at, "invalid Unicode escape sequence"))?; + match char::from_u32(code_point) { + Some(ch) if code_point > 0xFFFF => value.extend(ch.encode_utf16(&mut [0; 2]).iter()), + _ => value.push(code_point as u16), + } + Ok(false) + } + Some('0') if !self.peek_at(1).is_some_and(is_decimal_digit) => { + self.pos += 1; + value.push(0); + Ok(false) + } + Some('0'..='7') => { + value.push(self.octal_escape(c)); + Ok(true) + } + Some('8' | '9') => { + self.pos += 1; + value.push(c as u16); + Ok(true) + } + _ => { + self.pos += 1; + value.push(c as u16); + Ok(false) + } + } + } + + /// Reads an octal escape whose first digit is `first`: up to three digits when it is 0 to 3, + /// up to two when it is 4 to 7. + fn octal_escape(&mut self, first: u32) -> u16 { + let max = if first <= u32::from(b'3') { 3 } else { 2 }; + let mut value = 0; + let mut n = 0; + while n < max + && let Some(d) = self.peek().filter(|&d| is_octal_digit(d)) + { + value = value * 8 + (d - u32::from(b'0')); + self.pos += 1; + n += 1; + } + value as u16 + } + + /// Reads a regular expression literal (section 7.8.5) and checks its flags and pattern. + fn regexp(&mut self, start: usize, line: u32, column: u32) -> Result { + let at = (start, line, column); + self.pos = start + 1; + let mut in_class = false; + loop { + match self.peek() { + None => return Err(Self::error_from(at, "unterminated regular expression literal")), + Some(c) if is_line_terminator(c) => return Err(Self::error_from(at, "unterminated regular expression literal")), + Some(BACKSLASH) => { + if self.peek_at(1).is_none_or(is_line_terminator) { + return Err(Self::error_from(at, "unterminated regular expression literal")); + } + self.pos += 2; + } + Some(c) if c == u32::from(b'[') => { + in_class = true; + self.pos += 1; + } + Some(c) if c == u32::from(b']') => { + in_class = false; + self.pos += 1; + } + Some(SLASH) if !in_class => break, + Some(_) => self.pos += 1, + } + } + let pattern = self.src[start + 1..self.pos].to_vec(); + self.pos += 1; + let mut flags = String::new(); + while let Some((c, len)) = self.code_point(self.pos) { + if c == BACKSLASH { + return Err(Self::error_from(at, "invalid regular expression flags")); + } + if !is_id_part(c) { + break; + } + flags.push(char::from_u32(c).unwrap_or('\u{fffd}')); + self.pos += len; + } + if !valid_flags(&flags) { + return Err(Self::error_from(at, "invalid regular expression flags")); + } + if let Err(message) = regexp::check(&pattern, self.max_depth) { + return Err(Self::error_from(at, format!("invalid regular expression: {message}"))); + } + Ok(self.token(TokenKind::RegExp { pattern, flags }, start, line, column, false)) + } + + /// Reads the longest punctuator that starts here. + fn punct(&mut self) -> Option { + let rest = &self.src[self.pos..]; + for (spelling, punct) in Punct::ALL { + let bytes = spelling.as_bytes(); + if rest.len() >= bytes.len() && rest.iter().zip(bytes).all(|(&u, &b)| u == u16::from(b)) { + self.pos += bytes.len(); + return Some(*punct); + } + } + None + } +} + +/// What skipping the blanks and comments before a token found. +enum Trivia { + Skipped { newline: bool }, + Directive(Box), +} + +/// A character of a directive's name. +fn is_name_char(c: u32) -> bool { + c < 0x80 && (char::from(c as u8).is_ascii_alphanumeric() || c == u32::from(b'_')) +} + +/// The flags `g`, `i` and `m`, each at most once (section 15.10.4.1). +fn valid_flags(flags: &str) -> bool { + let mut seen = [false; 3]; + flags.chars().all(|c| { + let i = match c { + 'g' => 0, + 'i' => 1, + 'm' => 2, + _ => return false, + }; + !std::mem::replace(&mut seen[i], true) + }) +} + +fn unexpected_character(c: u32) -> String { + match char::from_u32(c) { + Some(ch) if !ch.is_control() => format!("unexpected character '{ch}'"), + _ => format!("unexpected character U+{c:04X}"), + } +} diff --git a/crates/js/src/lib.rs b/crates/js/src/lib.rs new file mode 100644 index 000000000..4d8d32271 --- /dev/null +++ b/crates/js/src/lib.rs @@ -0,0 +1,149 @@ +//! JavaScript for VectorCraft's scripts: an ECMAScript 5.1 lexer and parser +//! (), with the legacy forms of Annex B that older scripts +//! use (octal literals and escapes outside strict mode, function declarations in blocks, the +//! lenient regular expression patterns). +//! +//! [`parse`] reads a script into a [`Program`]: a syntax tree whose expressions, statements and +//! functions live in arenas and carry their source spans. A script that breaks the grammar or one +//! of its early-error rules (section 16: strict mode's restrictions, duplicate parameters in strict +//! code, invalid assignment targets, `break` and `continue` without a target, regular expression +//! literals that are not valid) gives a [`SyntaxError`] with its line and column. +//! +//! With [`Options::preprocessor_lines`], the lexer also reads the preprocessor lines scripts begin +//! with (`#include`, `#includepath`, `#script`, `#strict`, `#target`, `#targetengine`) into +//! [`Directive`]s. They are kept in [`Program::directives`] and never run. +//! +//! The lexer reads the source as UTF-16 code units, as JavaScript does: columns and spans count +//! code units. Nesting is counted: input nested deeper than [`Options::max_depth`] gives a +//! `SyntaxError` ("nesting too deep") instead of exhausting the stack, binary operators, `if` … +//! `else if` chains, labels and prefix operators are read with loops, and the syntax tree is +//! dropped without recursion. +#![forbid(unsafe_code)] + +pub mod ast; +mod chars; +mod error; +mod lexer; +mod number; +mod parser; +mod regexp; +mod token; + +pub use ast::{ + AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionId, FunctionKind, LogicalOp, Name, Program, Property, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, +}; +pub use error::{SyntaxError, TOO_DEEP, TOO_LONG}; +pub use lexer::{Goal, Lexer}; +pub use number::to_string as number_to_string; +pub use token::{Directive, DirectiveName, Keyword, Punct, Span, Token, TokenKind}; + +/// How a script is read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Options { + /// The script is strict mode code from its start, as code passed to a strict `eval` is. + pub strict: bool, + /// Read preprocessor lines (`#target …` and the other directives) into [`Directive`]s. Off, + /// a `#` is an unexpected character, as ECMA-262 has it. + pub preprocessor_lines: bool, + /// How deeply statements, expressions, functions and regular expression groups may nest. + pub max_depth: usize, + /// The most UTF-16 code units a source may hold. + pub max_source_units: usize, +} + +impl Default for Options { + fn default() -> Self { + Self { strict: false, preprocessor_lines: false, max_depth: DEFAULT_MAX_DEPTH, max_source_units: 64 << 20 } + } +} + +/// The default [`Options::max_depth`]: deeper than scripts nest, and shallow enough for the stack +/// of a wasm build or a secondary thread. +pub const DEFAULT_MAX_DEPTH: usize = 200; + +/// Parses `source` as a script. +pub fn parse(source: &str, options: &Options) -> Result { + let units = to_utf16(source, options)?; + parse_utf16(&units, options) +} + +/// Parses `source`, in UTF-16 code units, as a script. +pub fn parse_utf16(source: &[u16], options: &Options) -> Result { + check_length(source.len(), options)?; + parser::Parser::new(source, options, false)?.parse_program().map(|(program, _)| program) +} + +/// The tokens of `source`, in order, as the parser reads them: preprocessor lines included, and +/// each `/` read as division or as the start of a regular expression literal as the grammar +/// decides. The source must be a valid script. +pub fn tokenize(source: &str, options: &Options) -> Result, SyntaxError> { + let units = to_utf16(source, options)?; + check_length(units.len(), options)?; + parser::Parser::new(&units, options, true)?.parse_program().map(|(_, tokens)| tokens) +} + +fn to_utf16(source: &str, options: &Options) -> Result, SyntaxError> { + // A source's UTF-16 length is at most its UTF-8 length; count only a source that may be too + // long. + if source.len() > options.max_source_units { + check_length(source.encode_utf16().count(), options)?; + } + Ok(source.encode_utf16().collect()) +} + +fn check_length(units: usize, options: &Options) -> Result<(), SyntaxError> { + if units > options.max_source_units || u32::try_from(units).is_err() { + return Err(SyntaxError { message: TOO_LONG.into(), line: 1, column: 1, offset: 0 }); + } + Ok(()) +} + +/// Where each line of a source starts, for turning offsets into lines and columns. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct LineTable { + starts: Vec, + /// The source ends with a line terminator. + ends_with_break: bool, + empty: bool, +} + +impl LineTable { + pub(crate) fn new(src: &[u16]) -> Self { + let mut starts = vec![0]; + let mut i = 0; + while let Some(&u) = src.get(i) { + i += 1; + let terminator = match u { + 0x0D if src.get(i) == Some(&0x0A) => { + i += 1; + true + } + 0x0A | 0x0D | 0x2028 | 0x2029 => true, + _ => false, + }; + if terminator { + starts.push(u32::try_from(i).unwrap_or(u32::MAX)); + } + } + let ends_with_break = src.last().is_some_and(|&u| matches!(u, 0x0A | 0x0D | 0x2028 | 0x2029)); + Self { starts, ends_with_break, empty: src.is_empty() } + } + + pub(crate) fn line_column(&self, offset: u32) -> (u32, u32) { + let line = self.starts.partition_point(|&s| s <= offset).max(1); + let start = self.starts.get(line - 1).copied().unwrap_or(0); + (u32::try_from(line).unwrap_or(u32::MAX), offset.saturating_sub(start) + 1) + } + + pub(crate) fn line_count(&self) -> u32 { + if self.empty { + return 0; + } + let breaks = self.starts.len() - 1; + u32::try_from(breaks + usize::from(!self.ends_with_break)).unwrap_or(u32::MAX) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/js/src/number.rs b/crates/js/src/number.rs new file mode 100644 index 000000000..4afd48076 --- /dev/null +++ b/crates/js/src/number.rs @@ -0,0 +1,69 @@ +//! Number to string conversion (ECMA-262 5.1 section 9.8.1), for numeric property names. + +/// The string ToString gives for the number `x`: the shortest decimal digits that read back as +/// `x`, laid out as section 9.8.1 says. +pub fn to_string(x: f64) -> String { + if x.is_nan() { + return "NaN".into(); + } + if x == 0.0 { + return "0".into(); + } + if x.is_infinite() { + return if x > 0.0 { "Infinity".into() } else { "-Infinity".into() }; + } + if x < 0.0 { + return format!("-{}", to_string(-x)); + } + // Rust's `{:e}` writes the shortest digits that round-trip: `d.ddde±x`. + let sci = format!("{x:e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + let digits: String = mantissa.chars().filter(char::is_ascii_digit).collect(); + let k = digits.len() as i64; + let n = exp.parse::().unwrap_or(0) + 1; + if k <= n && n <= 21 { + let zeros = usize::try_from(n - k).unwrap_or(0); + format!("{digits}{}", "0".repeat(zeros)) + } else if 0 < n && n <= 21 { + let (int, frac) = digits.split_at_checked(usize::try_from(n).unwrap_or(0)).unwrap_or((&digits, "")); + format!("{int}.{frac}") + } else if -6 < n && n <= 0 { + let zeros = usize::try_from(-n).unwrap_or(0); + format!("0.{}{digits}", "0".repeat(zeros)) + } else { + let e = n - 1; + let sign = if e < 0 { '-' } else { '+' }; + let (first, rest) = digits.split_at_checked(1).unwrap_or((&digits, "")); + if rest.is_empty() { format!("{first}e{sign}{}", e.abs()) } else { format!("{first}.{rest}e{sign}{}", e.abs()) } + } +} + +#[cfg(test)] +mod tests { + use super::to_string; + + #[test] + fn numbers_print_as_section_9_8_1_lays_them_out() { + let cases: &[(f64, &str)] = &[ + (0.0, "0"), + (-0.0, "0"), + (1.0, "1"), + (-1.5, "-1.5"), + (123.456, "123.456"), + (1e21, "1e+21"), + (1e20, "100000000000000000000"), + (0.000001, "0.000001"), + (1e-7, "1e-7"), + (1.5e-7, "1.5e-7"), + (0.1 + 0.2, "0.30000000000000004"), + (f64::NAN, "NaN"), + (f64::INFINITY, "Infinity"), + (f64::NEG_INFINITY, "-Infinity"), + (5e-324, "5e-324"), + (1.7976931348623157e308, "1.7976931348623157e+308"), + ]; + for (x, want) in cases { + assert_eq!(to_string(*x), *want, "{x:?}"); + } + } +} diff --git a/crates/js/src/parser.rs b/crates/js/src/parser.rs new file mode 100644 index 000000000..d56b75617 --- /dev/null +++ b/crates/js/src/parser.rs @@ -0,0 +1,1236 @@ +//! The parser: tokens into a [`Program`] (ECMA-262 5.1 sections 11 to 14), with the early errors +//! of section 16 and of strict mode (Annex C). +//! +//! It is a recursive-descent parser whose recursion is counted: each statement, assignment +//! expression, primary expression and function body it enters adds one to a depth, and going past +//! [`crate::Options::max_depth`] gives a `SyntaxError`. Binary operators are read with an operator +//! stack, and prefix operators, assignment chains, `else if` chains and labels with loops, so long +//! runs of them do not nest at all. + +use std::collections::{HashMap, HashSet}; + +use crate::LineTable; +use crate::ast::{ + AssignOp, Ast, BinaryOp, CatchClause, Expr, ExprId, ForInTarget, ForInit, Function, FunctionKind, LogicalOp, Name, Program, Property, + PropertyKey, PropertyValue, Stmt, StmtId, SwitchCase, UnaryOp, UpdateOp, VarDecl, +}; +use crate::error::{SyntaxError, TOO_DEEP}; +use crate::lexer::{Goal, Lexer}; +use crate::token::{Directive, Keyword, Punct, Span, Token, TokenKind, strict_reserved}; + +type Result = std::result::Result; + +/// What the code being parsed allows, saved and replaced at each function body. +#[derive(Clone, Debug, Default)] +struct Context { + strict: bool, + in_function: bool, + /// The labels in scope, each with whether it labels an iteration statement (`continue` may + /// name only those). Labels in scope have distinct names. + labels: HashMap, + /// Iteration statements open in the current function. + loops: u32, + /// Iteration and `switch` statements open in the current function. + breakables: u32, +} + +/// A binary operator, with the logical ones apart. +#[derive(Clone, Copy, Debug)] +enum BinKind { + Binary(BinaryOp), + Logical(LogicalOp), +} + +/// A prefix operator. +#[derive(Clone, Copy, Debug)] +enum Prefix { + Unary(UnaryOp), + Update(UpdateOp), +} + +pub(crate) struct Parser<'s> { + src: &'s [u16], + lex: Lexer<'s>, + lines: LineTable, + tok: Token, + /// Where the last token read ends. + prev_end: u32, + ast: Ast, + depth: usize, + max_depth: usize, + ctx: Context, + directives: Vec, + /// The tokens read, kept for [`crate::tokenize`]. + tokens: Option>, + /// The next statement may be a function declaration outside strict mode code: it is an `if` + /// branch or the body of a label in a statement list (ECMA-262 2015 Annex B.3.2 and B.3.4). + /// Read and cleared as each statement starts. + function_allowed: bool, + /// The next statement stands in a statement list, where a label's body may be a function + /// declaration. Read and cleared as each statement starts. + in_list: bool, +} + +impl<'s> Parser<'s> { + pub(crate) fn new(src: &'s [u16], options: &crate::Options, keep_tokens: bool) -> Result { + let lex = Lexer::new(src, options.preprocessor_lines, options.max_depth); + let placeholder = + Token { kind: TokenKind::Eof, span: Span::default(), line: 1, column: 1, newline_before: false, escaped: false, legacy_octal: false }; + let mut parser = Self { + src, + lex, + lines: LineTable::new(src), + tok: placeholder, + prev_end: 0, + ast: Ast::default(), + depth: 0, + max_depth: options.max_depth, + ctx: Context { strict: options.strict, ..Context::default() }, + directives: Vec::new(), + tokens: keep_tokens.then(Vec::new), + function_allowed: false, + in_list: false, + }; + parser.tok = parser.next_significant()?; + Ok(parser) + } + + pub(crate) fn parse_program(mut self) -> Result<(Program, Vec)> { + let body = self.parse_body(false)?; + if self.tok.kind != TokenKind::Eof { + return Err(self.unexpected()); + } + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let program = Program { ast: self.ast, body, strict: self.ctx.strict, directives: self.directives, lines: self.lines }; + Ok((program, self.tokens.unwrap_or_default())) + } + + // ---- tokens ---- + + /// The next token that is not a preprocessor line; preprocessor lines are kept. + fn next_significant(&mut self) -> Result { + loop { + let token = self.lex.next_token(Goal::Div)?; + if let TokenKind::Directive(directive) = &token.kind { + self.directives.push(directive.clone()); + if let Some(tokens) = &mut self.tokens { + tokens.push(token); + } + continue; + } + return Ok(token); + } + } + + /// Reads the current token and moves to the next. + fn bump(&mut self) -> Result { + // Kept before reading on, so a preprocessor line after the token comes after it. + if let Some(tokens) = &mut self.tokens { + tokens.push(self.tok.clone()); + } + let next = self.next_significant()?; + let token = std::mem::replace(&mut self.tok, next); + self.prev_end = token.span.end; + Ok(token) + } + + /// The token after the current one, without reading it. `None` when the lexer cannot read one + /// (reading it for real reports the error). + fn peek(&self) -> Option { + let mut lex = self.lex.clone(); + loop { + match lex.next_token(Goal::Div) { + Ok(Token { kind: TokenKind::Directive(_), .. }) => {} + Ok(token) => return Some(token), + Err(_) => return None, + } + } + } + + fn at(&self, p: Punct) -> bool { + self.tok.is_punct(p) + } + + fn at_keyword(&self, k: Keyword) -> bool { + self.tok.is_keyword(k) + } + + fn eat(&mut self, p: Punct) -> Result { + if self.at(p) { + self.bump()?; + return Ok(true); + } + Ok(false) + } + + fn expect(&mut self, p: Punct) -> Result { + if self.at(p) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", p.as_str(), describe(&self.tok)))) + } + + fn expect_keyword(&mut self, k: Keyword) -> Result { + if self.at_keyword(k) { + return self.bump(); + } + Err(self.error_at_token(&self.tok, format!("expected '{}' but found {}", k.as_str(), describe(&self.tok)))) + } + + /// Ends a statement: a `;`, or one inserted before `}`, the end of the source or a token on a + /// new line (section 7.9). + fn semicolon(&mut self) -> Result<()> { + if self.eat(Punct::Semicolon)? || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before { + return Ok(()); + } + Err(self.unexpected()) + } + + // ---- errors ---- + + fn error_at_offset(&self, offset: u32, message: impl Into) -> SyntaxError { + let (line, column) = self.lines.line_column(offset); + SyntaxError { message: message.into(), line, column, offset } + } + + fn error_at_token(&self, token: &Token, message: impl Into) -> SyntaxError { + SyntaxError { message: message.into(), line: token.line, column: token.column, offset: token.span.start } + } + + fn unexpected(&self) -> SyntaxError { + self.error_at_token(&self.tok, format!("unexpected {}", describe(&self.tok))) + } + + /// Counts one more level of nesting; past the limit, a `SyntaxError` at the current token. + fn enter(&mut self) -> Result<()> { + if self.depth >= self.max_depth { + return Err(self.error_at_token(&self.tok, TOO_DEEP)); + } + self.depth += 1; + Ok(()) + } + + fn leave(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + fn span_from(&self, start: Span) -> Span { + Span::new(start.start, self.prev_end.max(start.start)) + } + + // ---- names ---- + + /// Checks a name used as an identifier: not a reserved word written with escapes, and not a + /// strict-mode reserved word in strict code. + fn check_identifier(&self, token: &Token, name: &str) -> Result<()> { + if token.escaped && Keyword::from_word(name).is_some() { + return Err(self.error_at_token(token, format!("keyword '{name}' must not contain escaped characters"))); + } + if self.ctx.strict && strict_reserved(name) { + return Err(self.error_at_token(token, format!("'{name}' is a reserved word in strict mode"))); + } + Ok(()) + } + + /// Reads an identifier that a declaration binds: checked as [`Self::check_identifier`] does, + /// and in strict code neither `eval` nor `arguments`. + fn binding_identifier(&mut self) -> Result { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected an identifier but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.strict && is_eval_or_arguments(&name) { + return Err(self.error_at_token(&self.tok, format!("'{name}' cannot be declared in strict mode"))); + } + let token = self.bump()?; + Ok(Name { name, span: token.span }) + } + + // ---- program and function bodies ---- + + /// Reads source elements up to `}` (in a function body) or the end of the source, reading the + /// directive prologue at their start (section 14.1). A `"use strict"` directive makes the rest + /// strict mode code. + fn parse_body(&mut self, in_function: bool) -> Result> { + let mut body = Vec::new(); + let mut prologue = true; + // A string with an octal escape before a `"use strict"` directive in the same prologue. + let mut octal: Option = None; + while !(self.tok.kind == TokenKind::Eof || (in_function && self.at(Punct::RBrace))) { + if prologue && matches!(self.tok.kind, TokenKind::String(_)) { + let first = self.tok.clone(); + let stmt = self.parse_statement_item()?; + body.push(stmt); + if !self.is_directive(stmt, &first) { + prologue = false; + continue; + } + if self.is_use_strict(&first) { + self.ctx.strict = true; + if let Some(octal) = &octal { + return Err(self.error_at_token(octal, "octal escape sequences are not allowed in strict mode")); + } + } + if first.legacy_octal && octal.is_none() { + octal = Some(first); + } + continue; + } + prologue = false; + body.push(self.parse_statement_item()?); + } + Ok(body) + } + + /// Whether `stmt`, which started at `first`, is a directive: an expression statement made of + /// that one string literal. + fn is_directive(&self, stmt: StmtId, first: &Token) -> bool { + match self.ast.stmt(stmt) { + Some(Stmt::Expr(e)) => matches!(self.ast.expr(*e), Some(Expr::String(_))) && self.ast.expr_span(*e) == first.span, + _ => false, + } + } + + /// The string token is exactly `"use strict"` or `'use strict'`, without escapes or line + /// continuations. + fn is_use_strict(&self, token: &Token) -> bool { + let raw = self.src.get(token.span.start as usize..token.span.end as usize).unwrap_or_default(); + raw.len() == 12 && raw.get(1..11).is_some_and(|inner| inner.iter().copied().eq("use strict".encode_utf16())) + } + + // ---- statements ---- + + /// A source element: a function declaration or a statement. + fn parse_statement_item(&mut self) -> Result { + if self.at_keyword(Keyword::Function) { + self.enter()?; + let result = self.parse_function_declaration(); + self.leave(); + return result; + } + self.in_list = true; + self.parse_statement() + } + + fn parse_statement(&mut self) -> Result { + self.enter()?; + let result = self.statement(); + self.leave(); + result + } + + /// A statement where Annex B lets sloppy code put a function declaration: an `if` branch. + fn parse_branch(&mut self) -> Result { + self.function_allowed = !self.ctx.strict; + self.parse_statement() + } + + fn statement(&mut self) -> Result { + let start = self.tok.span; + let function_allowed = std::mem::take(&mut self.function_allowed); + let in_list = std::mem::take(&mut self.in_list); + match &self.tok.kind { + TokenKind::Keyword(Keyword::Function) => { + if !function_allowed { + let message = if self.ctx.strict { + "in strict mode code, functions can only be declared at top level or inside a block" + } else { + "a function declaration cannot stand here; put it in a block" + }; + return Err(self.error_at_token(&self.tok, message)); + } + self.parse_function_declaration() + } + TokenKind::Ident(_) if self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) => self.parse_labeled(in_list), + _ => self.plain_statement(start), + } + } + + fn plain_statement(&mut self, start: Span) -> Result { + match &self.tok.kind { + TokenKind::Punct(Punct::LBrace) => { + let body = self.parse_block()?; + Ok(self.ast.push_stmt(Stmt::Block(body), self.span_from(start))) + } + TokenKind::Punct(Punct::Semicolon) => { + self.bump()?; + Ok(self.ast.push_stmt(Stmt::Empty, start)) + } + TokenKind::Keyword(k) => self.keyword_statement(*k, start), + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + fn keyword_statement(&mut self, k: Keyword, start: Span) -> Result { + match k { + Keyword::Var => { + self.bump()?; + let decls = self.parse_var_decls(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Var(decls), self.span_from(start))) + } + Keyword::If => self.parse_if(), + Keyword::Do => self.parse_do_while(start), + Keyword::While => self.parse_while(start), + Keyword::For => self.parse_for(start), + Keyword::Continue | Keyword::Break => self.parse_jump(k == Keyword::Break, start), + Keyword::Return => self.parse_return(start), + Keyword::With => self.parse_with(start), + Keyword::Switch => self.parse_switch(start), + Keyword::Throw => self.parse_throw(start), + Keyword::Try => self.parse_try(start), + Keyword::Debugger => { + self.bump()?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Debugger, self.span_from(start))) + } + _ => { + let expr = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Expr(expr), self.span_from(start))) + } + } + } + + /// `{ … }`: its statements. + fn parse_block(&mut self) -> Result> { + self.expect(Punct::LBrace)?; + let mut body = Vec::new(); + while !self.at(Punct::RBrace) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + self.bump()?; + Ok(body) + } + + /// `var` declarations after the `var`. `no_in` leaves `in` to a `for (… in …)` around them. + fn parse_var_decls(&mut self, no_in: bool) -> Result> { + let mut decls = Vec::new(); + loop { + let name = self.binding_identifier()?; + let init = if self.eat(Punct::Eq)? { Some(self.parse_assignment(no_in)?) } else { None }; + decls.push(VarDecl { name, init }); + if !self.eat(Punct::Comma)? { + return Ok(decls); + } + } + } + + /// `if (…) … else if (…) … else …`, the `else if` chain read with a loop. + fn parse_if(&mut self) -> Result { + let mut chain = Vec::new(); + let alternate = loop { + let start = self.expect_keyword(Keyword::If)?.span; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let consequent = self.parse_branch()?; + chain.push((start, test, consequent)); + if !self.at_keyword(Keyword::Else) { + break None; + } + self.bump()?; + if !self.at_keyword(Keyword::If) { + break Some(self.parse_branch()?); + } + }; + let mut alternate = alternate; + let mut last = None; + for (start, test, consequent) in chain.into_iter().rev() { + let id = self.ast.push_stmt(Stmt::If { test, consequent, alternate }, self.span_from(start)); + alternate = Some(id); + last = Some(id); + } + last.ok_or_else(|| self.unexpected()) + } + + /// The body of an iteration statement, with `break` and `continue` allowed in it. + fn parse_loop_body(&mut self) -> Result { + self.ctx.loops += 1; + self.ctx.breakables += 1; + let body = self.parse_statement(); + self.ctx.loops -= 1; + self.ctx.breakables -= 1; + body + } + + fn parse_do_while(&mut self, start: Span) -> Result { + self.bump()?; + let body = self.parse_loop_body()?; + self.expect_keyword(Keyword::While)?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + // A `;` after `do … while (…)` may be left out even on the same line, as ECMA-262 2015 + // section 11.9.1 allows. + self.eat(Punct::Semicolon)?; + Ok(self.ast.push_stmt(Stmt::DoWhile { body, test }, self.span_from(start))) + } + + fn parse_while(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let test = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::While { test, body }, self.span_from(start))) + } + + fn parse_for(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let init = if self.at(Punct::Semicolon) { + None + } else if self.at_keyword(Keyword::Var) { + self.bump()?; + let mut decls = self.parse_var_decls(true)?; + if decls.len() == 1 && self.at_keyword(Keyword::In) { + let decl = decls.pop().ok_or_else(|| self.unexpected())?; + // The variable of a for-in loop takes an initializer only outside strict mode + // code (ECMA-262 2017 Annex B.3.6). + if self.ctx.strict && decl.init.is_some() { + return Err(self.error_at_token(&self.tok, "a for-in variable cannot have an initializer in strict mode code")); + } + return self.parse_for_in(ForInTarget::Var(decl), start); + } + Some(ForInit::Var(decls)) + } else { + let expr = self.parse_expression(true)?; + if self.at_keyword(Keyword::In) { + self.check_target(expr, "invalid left-hand side in for-in loop")?; + return self.parse_for_in(ForInTarget::Expr(expr), start); + } + Some(ForInit::Expr(expr)) + }; + self.expect(Punct::Semicolon)?; + let test = if self.at(Punct::Semicolon) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::Semicolon)?; + let update = if self.at(Punct::RParen) { None } else { Some(self.parse_expression(false)?) }; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::For { init, test, update, body }, self.span_from(start))) + } + + /// The rest of `for (target in object) body`, from `in`. + fn parse_for_in(&mut self, target: ForInTarget, start: Span) -> Result { + self.expect_keyword(Keyword::In)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_loop_body()?; + Ok(self.ast.push_stmt(Stmt::ForIn { target, object, body }, self.span_from(start))) + } + + /// `break` or `continue`, with an optional label on the same line, which must name a label + /// around it in the same function (an iteration statement's, for `continue`). + fn parse_jump(&mut self, is_break: bool, start: Span) -> Result { + let keyword = self.bump()?; + let label = match &self.tok.kind { + TokenKind::Ident(name) if !self.tok.newline_before => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + match self.ctx.labels.get(&name) { + None => return Err(self.error_at_token(&token, format!("undefined label '{name}'"))), + Some(&is_loop) if !is_break && !is_loop => { + return Err(self.error_at_token(&token, format!("label '{name}' does not name a loop, so continue cannot use it"))); + } + Some(_) => {} + } + Some(Name { name, span: token.span }) + } + _ => None, + }; + if label.is_none() { + if is_break && self.ctx.breakables == 0 { + return Err(self.error_at_token(&keyword, "break outside a loop or switch")); + } + if !is_break && self.ctx.loops == 0 { + return Err(self.error_at_token(&keyword, "continue outside a loop")); + } + } + self.semicolon()?; + let stmt = if is_break { Stmt::Break(label) } else { Stmt::Continue(label) }; + Ok(self.ast.push_stmt(stmt, self.span_from(start))) + } + + fn parse_return(&mut self, start: Span) -> Result { + let keyword = self.bump()?; + if !self.ctx.in_function { + return Err(self.error_at_token(&keyword, "return outside a function")); + } + let ends = self.at(Punct::Semicolon) || self.at(Punct::RBrace) || self.tok.kind == TokenKind::Eof || self.tok.newline_before; + let arg = if ends { None } else { Some(self.parse_expression(false)?) }; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Return(arg), self.span_from(start))) + } + + fn parse_with(&mut self, start: Span) -> Result { + if self.ctx.strict { + return Err(self.error_at_token(&self.tok, "with statements are not allowed in strict mode")); + } + self.bump()?; + self.expect(Punct::LParen)?; + let object = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + let body = self.parse_statement()?; + Ok(self.ast.push_stmt(Stmt::With { object, body }, self.span_from(start))) + } + + fn parse_switch(&mut self, start: Span) -> Result { + self.bump()?; + self.expect(Punct::LParen)?; + let discriminant = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + self.expect(Punct::LBrace)?; + self.ctx.breakables += 1; + let cases = self.parse_cases(); + self.ctx.breakables -= 1; + let cases = cases?; + Ok(self.ast.push_stmt(Stmt::Switch { discriminant, cases }, self.span_from(start))) + } + + /// A `switch` statement's clauses, up to and with its `}`; at most one `default`. + fn parse_cases(&mut self) -> Result> { + let mut cases = Vec::new(); + let mut has_default = false; + while !self.eat(Punct::RBrace)? { + let case_start = self.tok.span; + let test = if self.at_keyword(Keyword::Case) { + self.bump()?; + Some(self.parse_expression(false)?) + } else if self.at_keyword(Keyword::Default) { + if has_default { + return Err(self.error_at_token(&self.tok, "more than one default clause in switch statement")); + } + has_default = true; + self.bump()?; + None + } else { + return Err(self.unexpected()); + }; + self.expect(Punct::Colon)?; + let mut body = Vec::new(); + while !(self.at_keyword(Keyword::Case) || self.at_keyword(Keyword::Default) || self.at(Punct::RBrace)) { + if self.tok.kind == TokenKind::Eof { + return Err(self.unexpected()); + } + body.push(self.parse_statement_item()?); + } + cases.push(SwitchCase { test, body, span: self.span_from(case_start) }); + } + Ok(cases) + } + + fn parse_throw(&mut self, start: Span) -> Result { + self.bump()?; + if self.tok.newline_before { + return Err(self.error_at_token(&self.tok, "line break after throw")); + } + let arg = self.parse_expression(false)?; + self.semicolon()?; + Ok(self.ast.push_stmt(Stmt::Throw(arg), self.span_from(start))) + } + + fn parse_try(&mut self, start: Span) -> Result { + self.bump()?; + let block = self.parse_block()?; + let handler = if self.at_keyword(Keyword::Catch) { + self.bump()?; + self.expect(Punct::LParen)?; + let param = self.binding_identifier()?; + self.expect(Punct::RParen)?; + Some(CatchClause { param, body: self.parse_block()? }) + } else { + None + }; + let finalizer = if self.at_keyword(Keyword::Finally) { + self.bump()?; + Some(self.parse_block()?) + } else { + None + }; + if handler.is_none() && finalizer.is_none() { + return Err(self.error_at_token(&self.tok, "missing catch or finally after try")); + } + Ok(self.ast.push_stmt(Stmt::Try { block, handler, finalizer }, self.span_from(start))) + } + + /// `a: b: statement`: the labels read with a loop, each unique among the labels around it. + /// `in_list`: the labeled statement stands in a statement list, so outside strict mode code + /// its body may be a function declaration. + fn parse_labeled(&mut self, in_list: bool) -> Result { + let mut labels: Vec<(Name, Span)> = Vec::new(); + let mut chain = HashSet::new(); + while let TokenKind::Ident(name) = &self.tok.kind { + if !self.peek().is_some_and(|t| t.is_punct(Punct::Colon)) { + break; + } + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + if self.ctx.labels.contains_key(&name) || !chain.insert(name.clone()) { + return Err(self.error_at_token(&self.tok, format!("duplicate label '{name}'"))); + } + let token = self.bump()?; + self.bump()?; + labels.push((Name { name, span: token.span }, token.span)); + } + let is_loop = self.at_keyword(Keyword::For) || self.at_keyword(Keyword::While) || self.at_keyword(Keyword::Do); + self.ctx.labels.extend(chain.into_iter().map(|name| (name, is_loop))); + self.function_allowed = in_list && !self.ctx.strict; + let body = self.parse_statement(); + for (label, _) in &labels { + self.ctx.labels.remove(&label.name); + } + let mut body = body?; + for (label, label_start) in labels.into_iter().rev() { + let span = Span::new(label_start.start, self.ast.stmt_span(body).end); + body = self.ast.push_stmt(Stmt::Labeled { label, body }, span); + } + Ok(body) + } + + // ---- functions ---- + + fn parse_function_declaration(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = self.function_name()?; + let id = self.parse_function_rest(FunctionKind::Declaration, Some(name), start)?; + Ok(self.ast.push_stmt(Stmt::FunctionDecl(id), self.span_from(start))) + } + + /// A function's name, checked as an identifier; the strict-mode checks wait for the body, + /// whose prologue may make the function strict. + fn function_name(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a function name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The parameters and body of a function, from `(`. + fn parse_function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result { + self.enter()?; + let result = self.function_rest(kind, name, start); + self.leave(); + result + } + + fn function_rest(&mut self, kind: FunctionKind, name: Option<(Name, Token)>, start: Span) -> Result { + self.expect(Punct::LParen)?; + let mut params: Vec<(Name, Token)> = Vec::new(); + if !self.at(Punct::RParen) { + loop { + let (param, token) = self.function_name_or_param()?; + params.push((param, token)); + if !self.eat(Punct::Comma)? { + break; + } + } + } + let close = self.expect(Punct::RParen)?; + match kind { + FunctionKind::Getter if !params.is_empty() => return Err(self.error_at_token(&close, "a getter must not have parameters")), + FunctionKind::Setter if params.len() != 1 => return Err(self.error_at_token(&close, "a setter must have exactly one parameter")), + _ => {} + } + self.expect(Punct::LBrace)?; + let inner = Context { strict: self.ctx.strict, in_function: true, ..Context::default() }; + let outer = std::mem::replace(&mut self.ctx, inner); + let body = self.parse_body(true); + let strict = self.ctx.strict; + self.ctx = outer; + let body = body?; + self.expect(Punct::RBrace)?; + if strict { + self.check_strict_function(name.as_ref(), ¶ms)?; + } + Ok(self.ast.push_function(Function { + kind, + name: name.map(|(n, _)| n), + params: params.into_iter().map(|(n, _)| n).collect(), + body, + strict, + span: self.span_from(start), + })) + } + + /// A parameter's name. + fn function_name_or_param(&mut self) -> Result<(Name, Token)> { + let TokenKind::Ident(name) = &self.tok.kind else { + return Err(self.error_at_token(&self.tok, format!("expected a parameter name but found {}", describe(&self.tok)))); + }; + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + let token = self.bump()?; + Ok((Name { name, span: token.span }, token)) + } + + /// The rules strict mode code sets for a function's name and parameters (Annex C): neither + /// `eval`, `arguments` nor a strict-mode reserved word, and no parameter name twice. + fn check_strict_function(&self, name: Option<&(Name, Token)>, params: &[(Name, Token)]) -> Result<()> { + for (n, token) in name.into_iter().chain(params) { + if is_eval_or_arguments(&n.name) { + return Err(self.error_at_token(token, format!("'{}' cannot be declared in strict mode", n.name))); + } + if strict_reserved(&n.name) { + return Err(self.error_at_token(token, format!("'{}' is a reserved word in strict mode", n.name))); + } + } + let mut seen = HashSet::new(); + for (p, token) in params { + if !seen.insert(p.name.as_str()) { + return Err(self.error_at_token(token, format!("duplicate parameter name '{}' is not allowed in strict mode", p.name))); + } + } + Ok(()) + } + + // ---- expressions ---- + + /// An expression: assignment expressions separated by commas. `no_in` leaves `in` to a + /// `for (… in …)` around it. + fn parse_expression(&mut self, no_in: bool) -> Result { + let first = self.parse_assignment(no_in)?; + if !self.at(Punct::Comma) { + return Ok(first); + } + let mut list = vec![first]; + while self.eat(Punct::Comma)? { + list.push(self.parse_assignment(no_in)?); + } + let span = self.ast.expr_span(first).to(Span::new(self.prev_end, self.prev_end)); + Ok(self.ast.push_expr(Expr::Sequence(list), span)) + } + + fn parse_assignment(&mut self, no_in: bool) -> Result { + self.enter()?; + let result = self.assignment(no_in); + self.leave(); + result + } + + /// `a = b += c`: the targets kept on a stack, so a long chain does not nest. + fn assignment(&mut self, no_in: bool) -> Result { + let mut targets: Vec<(ExprId, AssignOp)> = Vec::new(); + loop { + let expr = self.parse_conditional(no_in)?; + let Some(op) = assign_op(&self.tok) else { + let mut value = expr; + while let Some((target, op)) = targets.pop() { + let span = self.ast.expr_span(target).to(self.ast.expr_span(value)); + value = self.ast.push_expr(Expr::Assign { op, target, value }, span); + } + return Ok(value); + }; + self.check_target(expr, "invalid assignment target")?; + self.bump()?; + targets.push((expr, op)); + } + } + + /// An assignment's, `++`'s, `--`'s or `for-in`'s target: an identifier (in strict code + /// neither `eval` nor `arguments`), a property, or outside strict mode code a call (assigning + /// to its result fails at run time, as ECMA-262 2015 Annex B and web browsers have it). + fn check_target(&self, expr: ExprId, message: &str) -> Result<()> { + let inner = self.ast.unparenthesized(expr); + match self.ast.expr(inner) { + Some(Expr::Ident(name)) => { + if self.ctx.strict && is_eval_or_arguments(name) { + return Err(self.error_at_offset(self.ast.expr_span(inner).start, format!("cannot assign to '{name}' in strict mode"))); + } + Ok(()) + } + Some(Expr::Member { .. } | Expr::Index { .. }) => Ok(()), + Some(Expr::Call { .. }) if !self.ctx.strict => Ok(()), + _ => Err(self.error_at_offset(self.ast.expr_span(expr).start, message)), + } + } + + fn parse_conditional(&mut self, no_in: bool) -> Result { + let test = self.parse_binary(no_in)?; + if !self.at(Punct::Question) { + return Ok(test); + } + self.bump()?; + let consequent = self.parse_assignment(false)?; + self.expect(Punct::Colon)?; + let alternate = self.parse_assignment(no_in)?; + let span = self.ast.expr_span(test).to(self.ast.expr_span(alternate)); + Ok(self.ast.push_expr(Expr::Conditional { test, consequent, alternate }, span)) + } + + /// Binary and logical operators, read with an operator stack by precedence (all are left + /// associative). + fn parse_binary(&mut self, no_in: bool) -> Result { + let mut operands = vec![self.parse_unary()?]; + let mut ops: Vec<(BinKind, u8)> = Vec::new(); + while let Some((op, precedence)) = binary_op(&self.tok, no_in) { + while ops.last().is_some_and(|&(_, top)| top >= precedence) { + self.reduce(&mut operands, &mut ops); + } + self.bump()?; + ops.push((op, precedence)); + operands.push(self.parse_unary()?); + } + while !ops.is_empty() { + self.reduce(&mut operands, &mut ops); + } + operands.pop().ok_or_else(|| self.unexpected()) + } + + /// Joins the top two operands with the top operator. + fn reduce(&mut self, operands: &mut Vec, ops: &mut Vec<(BinKind, u8)>) { + let (Some((op, _)), Some(right), Some(left)) = (ops.pop(), operands.pop(), operands.pop()) else { + return; + }; + let span = self.ast.expr_span(left).to(self.ast.expr_span(right)); + let expr = match op { + BinKind::Binary(op) => Expr::Binary { op, left, right }, + BinKind::Logical(op) => Expr::Logical { op, left, right }, + }; + operands.push(self.ast.push_expr(expr, span)); + } + + /// Prefix operators, read with a loop and applied innermost first. + fn parse_unary(&mut self) -> Result { + let mut prefixes = Vec::new(); + while let Some(prefix) = prefix_op(&self.tok) { + prefixes.push((prefix, self.tok.span)); + self.bump()?; + } + let mut expr = self.parse_postfix()?; + for (prefix, start) in prefixes.into_iter().rev() { + let span = start.to(self.ast.expr_span(expr)); + expr = match prefix { + Prefix::Update(op) => { + self.check_target(expr, "invalid increment or decrement operand")?; + self.ast.push_expr(Expr::Update { op, prefix: true, target: expr }, span) + } + Prefix::Unary(op) => { + if op == UnaryOp::Delete && self.ctx.strict && matches!(self.ast.expr(self.ast.unparenthesized(expr)), Some(Expr::Ident(_))) { + return Err(self.error_at_offset(start.start, "delete of an unqualified identifier in strict mode")); + } + self.ast.push_expr(Expr::Unary { op, arg: expr }, span) + } + }; + } + Ok(expr) + } + + fn parse_postfix(&mut self) -> Result { + let expr = self.parse_left_hand_side()?; + let op = match &self.tok.kind { + TokenKind::Punct(Punct::PlusPlus) if !self.tok.newline_before => UpdateOp::Increment, + TokenKind::Punct(Punct::MinusMinus) if !self.tok.newline_before => UpdateOp::Decrement, + _ => return Ok(expr), + }; + self.check_target(expr, "invalid increment or decrement operand")?; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + Ok(self.ast.push_expr(Expr::Update { op, prefix: false, target: expr }, span)) + } + + /// Member accesses, calls and `new` (section 11.2): each `new` takes the first argument list + /// after its callee, and a `new` left without one constructs with no arguments. + fn parse_left_hand_side(&mut self) -> Result { + let mut news = Vec::new(); + while self.at_keyword(Keyword::New) { + news.push(self.bump()?.span); + } + let mut expr = self.parse_primary()?; + loop { + match &self.tok.kind { + TokenKind::Punct(Punct::Dot) => { + self.bump()?; + let Some(name) = self.tok.identifier_name().map(str::to_string) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + let token = self.bump()?; + let span = self.ast.expr_span(expr).to(token.span); + expr = self.ast.push_expr(Expr::Member { object: expr, property: Name { name, span: token.span } }, span); + } + TokenKind::Punct(Punct::LBracket) => { + self.bump()?; + let index = self.parse_expression(false)?; + let close = self.expect(Punct::RBracket)?; + let span = self.ast.expr_span(expr).to(close.span); + expr = self.ast.push_expr(Expr::Index { object: expr, index }, span); + } + TokenKind::Punct(Punct::LParen) => { + let args = self.parse_arguments()?; + let span = Span::new(self.ast.expr_span(expr).start, self.prev_end); + expr = match news.pop() { + Some(new) => self.ast.push_expr(Expr::New { callee: expr, args }, Span::new(new.start, self.prev_end)), + None => self.ast.push_expr(Expr::Call { callee: expr, args }, span), + }; + } + _ => break, + } + } + while let Some(new) = news.pop() { + let span = Span::new(new.start, self.ast.expr_span(expr).end); + expr = self.ast.push_expr(Expr::New { callee: expr, args: Vec::new() }, span); + } + Ok(expr) + } + + fn parse_arguments(&mut self) -> Result> { + self.expect(Punct::LParen)?; + let mut args = Vec::new(); + if !self.at(Punct::RParen) { + loop { + args.push(self.parse_assignment(false)?); + if !self.eat(Punct::Comma)? { + break; + } + } + } + self.expect(Punct::RParen)?; + Ok(args) + } + + fn parse_primary(&mut self) -> Result { + self.enter()?; + let result = self.primary(); + self.leave(); + result + } + + fn primary(&mut self) -> Result { + let start = self.tok.span; + let expr = match &self.tok.kind { + TokenKind::Keyword(Keyword::This) => Expr::This, + TokenKind::Keyword(Keyword::Null) => Expr::Null, + TokenKind::Keyword(Keyword::True) => Expr::Bool(true), + TokenKind::Keyword(Keyword::False) => Expr::Bool(false), + TokenKind::Keyword(Keyword::Function) => return self.parse_function_expression(), + TokenKind::Ident(name) => { + let name = name.clone(); + self.check_identifier(&self.tok, &name)?; + Expr::Ident(name) + } + TokenKind::Number(n) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals are not allowed in strict mode")); + } + Expr::Number(*n) + } + TokenKind::String(s) => { + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal escape sequences are not allowed in strict mode")); + } + Expr::String(s.clone()) + } + TokenKind::Punct(Punct::Slash | Punct::SlashEq) => { + let regexp = self.lex.rescan_regexp(&self.tok)?; + self.tok = regexp; + let TokenKind::RegExp { pattern, flags } = &self.tok.kind else { + return Err(self.unexpected()); + }; + Expr::RegExp { pattern: pattern.clone(), flags: flags.clone() } + } + TokenKind::Punct(Punct::LBracket) => return self.parse_array(), + TokenKind::Punct(Punct::LBrace) => return self.parse_object(), + TokenKind::Punct(Punct::LParen) => { + self.bump()?; + let inner = self.parse_expression(false)?; + self.expect(Punct::RParen)?; + return Ok(self.ast.push_expr(Expr::Paren(inner), self.span_from(start))); + } + _ => return Err(self.unexpected()), + }; + let token = self.bump()?; + Ok(self.ast.push_expr(expr, token.span)) + } + + fn parse_function_expression(&mut self) -> Result { + let start = self.expect_keyword(Keyword::Function)?.span; + let name = if matches!(self.tok.kind, TokenKind::Ident(_)) { Some(self.function_name()?) } else { None }; + let id = self.parse_function_rest(FunctionKind::Expression, name, start)?; + Ok(self.ast.push_expr(Expr::Function(id), self.span_from(start))) + } + + /// `[a, , b]`: elements, a comma with nothing before it leaving a hole. + fn parse_array(&mut self) -> Result { + let start = self.expect(Punct::LBracket)?.span; + let mut elements = Vec::new(); + loop { + if self.eat(Punct::RBracket)? { + break; + } + if self.eat(Punct::Comma)? { + elements.push(None); + continue; + } + elements.push(Some(self.parse_assignment(false)?)); + if !self.at(Punct::RBracket) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Array(elements), self.span_from(start))) + } + + /// `{a: 1, "b": 2, 3: c, get d() {…}, set d(v) {…}}`. A name may repeat, a later definition + /// replacing an earlier one (ECMA-262 2015 section 12.2.6.1). + fn parse_object(&mut self) -> Result { + let start = self.expect(Punct::LBrace)?.span; + let mut properties = Vec::new(); + while !self.eat(Punct::RBrace)? { + let property = self.parse_property()?; + properties.push(property); + if !self.at(Punct::RBrace) { + self.expect(Punct::Comma)?; + } + } + Ok(self.ast.push_expr(Expr::Object(properties), self.span_from(start))) + } + + fn parse_property(&mut self) -> Result { + let start = self.tok.span; + // `get` and `set` start an accessor only when written without escapes. + let accessor = match self.tok.ident().filter(|_| !self.tok.escaped) { + Some("get") => Some(FunctionKind::Getter), + Some("set") => Some(FunctionKind::Setter), + _ => None, + }; + if let Some(kind) = accessor { + let word = self.bump()?; + if property_key(&self.tok).is_some() { + let (key, _) = self.parse_property_key()?; + let function = self.parse_function_rest(kind, None, start)?; + let value = if kind == FunctionKind::Getter { PropertyValue::Get(function) } else { PropertyValue::Set(function) }; + return Ok(Property { key, value, span: self.span_from(start) }); + } + let key = PropertyKey::Ident(word.ident().unwrap_or_default().to_string()); + return self.property_value(key, start); + } + let (key, _) = self.parse_property_key()?; + self.property_value(key, start) + } + + /// `: value` after a data property's name. + fn property_value(&mut self, key: PropertyKey, start: Span) -> Result { + self.expect(Punct::Colon)?; + let value = self.parse_assignment(false)?; + Ok(Property { key, value: PropertyValue::Init(value), span: self.span_from(start) }) + } + + fn parse_property_key(&mut self) -> Result<(PropertyKey, Token)> { + let Some(key) = property_key(&self.tok) else { + return Err(self.error_at_token(&self.tok, format!("expected a property name but found {}", describe(&self.tok)))); + }; + if self.ctx.strict && self.tok.legacy_octal { + return Err(self.error_at_token(&self.tok, "octal literals and escape sequences are not allowed in strict mode")); + } + let token = self.bump()?; + Ok((key, token)) + } +} + +/// The key a token gives as a property name: an identifier name, a string or a number. +fn property_key(token: &Token) -> Option { + match &token.kind { + TokenKind::String(s) => Some(PropertyKey::String(s.clone())), + TokenKind::Number(n) => Some(PropertyKey::Number(*n)), + _ => token.identifier_name().map(|n| PropertyKey::Ident(n.to_string())), + } +} + +fn is_eval_or_arguments(name: &str) -> bool { + name == "eval" || name == "arguments" +} + +fn assign_op(token: &Token) -> Option { + let TokenKind::Punct(p) = &token.kind else { return None }; + Some(match p { + Punct::Eq => None, + Punct::PlusEq => Some(BinaryOp::Add), + Punct::MinusEq => Some(BinaryOp::Sub), + Punct::StarEq => Some(BinaryOp::Mul), + Punct::SlashEq => Some(BinaryOp::Div), + Punct::PercentEq => Some(BinaryOp::Mod), + Punct::ShlEq => Some(BinaryOp::Shl), + Punct::ShrEq => Some(BinaryOp::Shr), + Punct::UShrEq => Some(BinaryOp::UShr), + Punct::AmpEq => Some(BinaryOp::BitAnd), + Punct::PipeEq => Some(BinaryOp::BitOr), + Punct::CaretEq => Some(BinaryOp::BitXor), + _ => return None, + }) +} + +/// A binary operator and its precedence (higher binds tighter); `in` is left out under `no_in`. +fn binary_op(token: &Token, no_in: bool) -> Option<(BinKind, u8)> { + use BinKind::{Binary as B, Logical as L}; + match &token.kind { + TokenKind::Keyword(Keyword::Instanceof) => Some((B(BinaryOp::Instanceof), 7)), + TokenKind::Keyword(Keyword::In) if !no_in => Some((B(BinaryOp::In), 7)), + TokenKind::Punct(p) => Some(match p { + Punct::PipePipe => (L(LogicalOp::Or), 1), + Punct::AmpAmp => (L(LogicalOp::And), 2), + Punct::Pipe => (B(BinaryOp::BitOr), 3), + Punct::Caret => (B(BinaryOp::BitXor), 4), + Punct::Amp => (B(BinaryOp::BitAnd), 5), + Punct::EqEq => (B(BinaryOp::Eq), 6), + Punct::NotEq => (B(BinaryOp::NotEq), 6), + Punct::EqEqEq => (B(BinaryOp::StrictEq), 6), + Punct::NotEqEq => (B(BinaryOp::StrictNotEq), 6), + Punct::Lt => (B(BinaryOp::Lt), 7), + Punct::Gt => (B(BinaryOp::Gt), 7), + Punct::LtEq => (B(BinaryOp::LtEq), 7), + Punct::GtEq => (B(BinaryOp::GtEq), 7), + Punct::Shl => (B(BinaryOp::Shl), 8), + Punct::Shr => (B(BinaryOp::Shr), 8), + Punct::UShr => (B(BinaryOp::UShr), 8), + Punct::Plus => (B(BinaryOp::Add), 9), + Punct::Minus => (B(BinaryOp::Sub), 9), + Punct::Star => (B(BinaryOp::Mul), 10), + Punct::Slash => (B(BinaryOp::Div), 10), + Punct::Percent => (B(BinaryOp::Mod), 10), + _ => return None, + }), + _ => None, + } +} + +fn prefix_op(token: &Token) -> Option { + Some(match &token.kind { + TokenKind::Keyword(Keyword::Delete) => Prefix::Unary(UnaryOp::Delete), + TokenKind::Keyword(Keyword::Void) => Prefix::Unary(UnaryOp::Void), + TokenKind::Keyword(Keyword::Typeof) => Prefix::Unary(UnaryOp::Typeof), + TokenKind::Punct(Punct::Plus) => Prefix::Unary(UnaryOp::Plus), + TokenKind::Punct(Punct::Minus) => Prefix::Unary(UnaryOp::Minus), + TokenKind::Punct(Punct::Tilde) => Prefix::Unary(UnaryOp::BitNot), + TokenKind::Punct(Punct::Bang) => Prefix::Unary(UnaryOp::Not), + TokenKind::Punct(Punct::PlusPlus) => Prefix::Update(UpdateOp::Increment), + TokenKind::Punct(Punct::MinusMinus) => Prefix::Update(UpdateOp::Decrement), + _ => return None, + }) +} + +/// A token as an error message names it. +fn describe(token: &Token) -> String { + match &token.kind { + TokenKind::Eof => "end of input".into(), + TokenKind::Ident(name) => format!("identifier '{name}'"), + TokenKind::Keyword(k) => format!("token '{}'", k.as_str()), + TokenKind::Punct(p) => format!("token '{}'", p.as_str()), + TokenKind::Number(_) => "number".into(), + TokenKind::String(_) => "string".into(), + TokenKind::RegExp { .. } => "regular expression".into(), + TokenKind::Directive(_) => "preprocessor line".into(), + } +} diff --git a/crates/js/src/regexp.rs b/crates/js/src/regexp.rs new file mode 100644 index 000000000..cda4f5307 --- /dev/null +++ b/crates/js/src/regexp.rs @@ -0,0 +1,191 @@ +//! The early check of a regular expression literal's pattern (ECMA-262 5.1 section 15.10.1, with +//! the lenient forms of Annex B that scripts rely on: a `]`, `{` or `}` that starts no class or +//! quantifier is a literal character, a lookahead may take a quantifier, and a class range with a +//! class escape at one end is a set of literal characters). +//! +//! The check reads the pattern once, front to back, counting open groups instead of recursing. + +/// Checks `pattern`, the text between a literal's slashes. Groups may nest `max_depth` deep. +pub fn check(pattern: &[u16], max_depth: usize) -> Result<(), String> { + let mut i = 0; + let mut depth = 0usize; + // The term before the current position may take a quantifier. + let mut quantifiable = false; + while let Some(&unit) = pattern.get(i) { + let c = char::from_u32(u32::from(unit)).unwrap_or('\u{fffd}'); + match c { + '\\' => { + let next = pattern.get(i + 1).ok_or("\\ at end of pattern")?; + quantifiable = !matches!(char::from_u32(u32::from(*next)), Some('b' | 'B')); + i += 2; + } + '(' => { + if pattern.get(i + 1) == Some(&u16::from(b'?')) { + match pattern.get(i + 2).and_then(|&u| char::from_u32(u32::from(u))) { + Some(':' | '=' | '!') => i += 3, + _ => return Err("invalid group".into()), + } + } else { + i += 1; + } + depth += 1; + if depth > max_depth { + return Err(crate::error::TOO_DEEP.into()); + } + quantifiable = false; + } + ')' => { + depth = depth.checked_sub(1).ok_or("unmatched ')'")?; + quantifiable = true; + i += 1; + } + '[' => { + i = class(pattern, i + 1)?; + quantifiable = true; + } + '*' | '+' | '?' => { + if !quantifiable { + return Err("nothing to repeat".into()); + } + i = lazy(pattern, i + 1); + quantifiable = false; + } + '{' => match braces(pattern, i + 1) { + Some((min, max, end)) => { + if !quantifiable { + return Err("nothing to repeat".into()); + } + if max.is_some_and(|max| max < min) { + return Err("numbers out of order in {} quantifier".into()); + } + i = lazy(pattern, end); + quantifiable = false; + } + None => { + quantifiable = true; + i += 1; + } + }, + '^' | '$' | '|' => { + quantifiable = false; + i += 1; + } + _ => { + quantifiable = true; + i += 1; + } + } + } + if depth > 0 { + return Err("unterminated group".into()); + } + Ok(()) +} + +/// After a quantifier ending before `i`: past the `?` that makes it lazy, if there is one. +fn lazy(pattern: &[u16], i: usize) -> usize { + if pattern.get(i) == Some(&u16::from(b'?')) { i + 1 } else { i } +} + +/// A `{n}`, `{n,}` or `{n,m}` quantifier whose `{` is before `i`: its bounds and the position +/// after its `}`. `None` when the text there is not one. +fn braces(pattern: &[u16], i: usize) -> Option<(u64, Option, usize)> { + let (min, mut i) = digits(pattern, i)?; + let max = if pattern.get(i) == Some(&u16::from(b',')) { + i += 1; + match digits(pattern, i) { + Some((max, end)) => { + i = end; + Some(max) + } + None => None, + } + } else { + Some(min) + }; + (pattern.get(i) == Some(&u16::from(b'}'))).then_some((min, max, i + 1)) +} + +/// Decimal digits at `i`: their value (saturated) and the position after them. +fn digits(pattern: &[u16], mut i: usize) -> Option<(u64, usize)> { + let start = i; + let mut value: u64 = 0; + while let Some(d) = pattern.get(i).and_then(|&u| char::from_u32(u32::from(u))).and_then(|c| c.to_digit(10)) { + value = value.saturating_mul(10).saturating_add(u64::from(d)); + i += 1; + } + (i > start).then_some((value, i)) +} + +/// Reads a character class whose `[` is before `i` and returns the position after its `]`. +fn class(pattern: &[u16], mut i: usize) -> Result { + if pattern.get(i) == Some(&u16::from(b'^')) { + i += 1; + } + // The last class atom's character (`None` for a class escape such as `\d`), when a `-` after it + // may make a range. + let mut prev: Option> = None; + loop { + let unit = *pattern.get(i).ok_or("unterminated character class")?; + if unit == u16::from(b']') { + return Ok(i + 1); + } + let (atom, next) = class_atom(pattern, i)?; + i = next; + let dash = pattern.get(i) == Some(&u16::from(b'-')) && pattern.get(i + 1).is_some_and(|&u| u != u16::from(b']')); + match prev.take() { + Some(low) => { + if let (Some(low), Some(high)) = (low, atom) + && low > high + { + return Err("range out of order in character class".into()); + } + } + None if dash => { + prev = Some(atom); + i += 1; + } + None => {} + } + } +} + +/// A class atom at `i`: its character (`None` for a class escape) and the position after it. +fn class_atom(pattern: &[u16], i: usize) -> Result<(Option, usize), String> { + let unit = u32::from(*pattern.get(i).ok_or("unterminated character class")?); + if unit != u32::from(b'\\') { + return Ok((Some(unit), i + 1)); + } + let next = u32::from(*pattern.get(i + 1).ok_or("\\ at end of pattern")?); + let c = char::from_u32(next).unwrap_or('\u{fffd}'); + let hex = |n: usize| -> Option { + (0..n).try_fold(0, |v, k| pattern.get(i + 2 + k).and_then(|&u| crate::chars::hex_value(u32::from(u))).map(|d| v * 16 + d)) + }; + Ok(match c { + 'd' | 'D' | 's' | 'S' | 'w' | 'W' => (None, i + 2), + 'b' => (Some(0x08), i + 2), + 'f' => (Some(0x0C), i + 2), + 'n' => (Some(0x0A), i + 2), + 'r' => (Some(0x0D), i + 2), + 't' => (Some(0x09), i + 2), + 'v' => (Some(0x0B), i + 2), + 'x' => hex(2).map_or((Some(next), i + 2), |v| (Some(v), i + 4)), + 'u' => hex(4).map_or((Some(next), i + 2), |v| (Some(v), i + 6)), + 'c' => match pattern.get(i + 2).map(|&u| u32::from(u)) { + Some(l) if char::from_u32(l).is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_') => (Some(l % 32), i + 3), + _ => (Some(u32::from(b'\\')), i + 1), + }, + '0'..='7' => { + let mut value = 0; + let mut j = i + 1; + while j < i + 4 + && let Some(d) = pattern.get(j).and_then(|&u| char::from_u32(u32::from(u))).and_then(|ch| ch.to_digit(8)) + { + value = value * 8 + d; + j += 1; + } + (Some(value), j) + } + _ => (Some(next), i + 2), + }) +} diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs new file mode 100644 index 000000000..fb357e1bf --- /dev/null +++ b/crates/js/src/tests.rs @@ -0,0 +1,804 @@ +use proptest::prelude::*; + +use crate::{ + BinaryOp, Expr, ExprId, ForInTarget, FunctionKind, Options, Program, PropertyValue, Stmt, StmtId, SyntaxError, TOO_DEEP, TokenKind, parse, + tokenize, +}; + +fn ok(src: &str) -> Program { + match parse(src, &Options::default()) { + Ok(p) => p, + Err(e) => panic!("{src:?} should parse: {e}"), + } +} + +fn err(src: &str) -> SyntaxError { + match parse(src, &Options::default()) { + Ok(_) => panic!("{src:?} should not parse"), + Err(e) => e, + } +} + +fn strict_err(src: &str) -> SyntaxError { + let full = format!("'use strict';\n{src}"); + match parse(&full, &Options::default()) { + Ok(_) => panic!("{src:?} should not parse in strict mode code"), + Err(e) => e, + } +} + +fn with_preprocessor() -> Options { + Options { preprocessor_lines: true, ..Options::default() } +} + +/// The expression of the program's only (or first) statement. +fn first_expr(p: &Program) -> ExprId { + match p.ast.stmt(p.body[0]) { + Some(Stmt::Expr(e)) => *e, + other => panic!("not an expression statement: {other:?}"), + } +} + +fn expr(p: &Program, id: ExprId) -> &Expr { + p.ast.expr(id).unwrap() +} + +fn stmt(p: &Program, id: StmtId) -> &Stmt { + p.ast.stmt(id).unwrap() +} + +fn utf16(s: &str) -> Vec { + s.encode_utf16().collect() +} + +// ---- expressions ---- + +#[test] +fn binary_operators_follow_precedence_and_associate_left() { + let p = ok("a + b * c - d;"); + let Expr::Binary { op: BinaryOp::Sub, left, right } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *right), Expr::Ident(n) if n == "d")); + let Expr::Binary { op: BinaryOp::Add, right: mul, .. } = expr(&p, *left) else { panic!() }; + assert!(matches!(expr(&p, *mul), Expr::Binary { op: BinaryOp::Mul, .. })); +} + +#[test] +fn logical_operators_bind_looser_than_comparisons() { + let p = ok("a || b && c == d;"); + let Expr::Logical { right, .. } = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::Logical { right: eq, .. } = expr(&p, *right) else { panic!() }; + assert!(matches!(expr(&p, *eq), Expr::Binary { op: BinaryOp::Eq, .. })); +} + +#[test] +fn assignment_associates_right() { + let p = ok("a = b += c;"); + let Expr::Assign { op: None, value, .. } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *value), Expr::Assign { op: Some(BinaryOp::Add), .. })); +} + +#[test] +fn new_takes_the_first_argument_list_after_its_callee() { + let p = ok("new a.b(1).c(2);"); + let Expr::Call { callee, args } = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(args.len(), 1); + let Expr::Member { object, property } = expr(&p, *callee) else { panic!() }; + assert_eq!(property.name, "c"); + let Expr::New { callee, args } = expr(&p, *object) else { panic!() }; + assert_eq!(args.len(), 1); + assert!(matches!(expr(&p, *callee), Expr::Member { .. })); + + let p = ok("new new X()();"); + let Expr::New { callee, .. } = expr(&p, first_expr(&p)) else { panic!() }; + assert!(matches!(expr(&p, *callee), Expr::New { .. })); + + let p = ok("new X;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::New { args, .. } if args.is_empty())); +} + +#[test] +fn conditional_and_sequence_expressions() { + let p = ok("a ? b : c ? d : e, f;"); + let Expr::Sequence(list) = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(list.len(), 2); + let Expr::Conditional { alternate, .. } = expr(&p, list[0]) else { panic!() }; + assert!(matches!(expr(&p, *alternate), Expr::Conditional { .. })); +} + +#[test] +fn array_literals_keep_holes() { + let p = ok("[1, , 3, ];"); + let Expr::Array(elements) = expr(&p, first_expr(&p)) else { panic!() }; + assert_eq!(elements.len(), 3); + assert!(elements[1].is_none()); + let p = ok("[,];"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Array(e) if e.len() == 1)); +} + +#[test] +fn object_literals_with_accessors_and_keyword_names() { + let p = ok("({a: 1, 'b': 2, 3: 3, if: 4, get: 5, set: 6, get x() { return 1; }, set x(v) {}, });"); + let Expr::Paren(inner) = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::Object(props) = expr(&p, *inner) else { panic!() }; + assert_eq!(props.len(), 8); + assert!(matches!(props[6].value, PropertyValue::Get(_))); + assert!(matches!(props[7].value, PropertyValue::Set(_))); + let PropertyValue::Get(getter) = props[6].value else { panic!() }; + assert_eq!(p.ast.function(getter).unwrap().kind, FunctionKind::Getter); +} + +#[test] +fn accessors_take_the_right_number_of_parameters() { + assert!(err("({get a(x) {}});").message.contains("getter")); + assert!(err("({set a() {}});").message.contains("setter")); + assert!(err("({set a(x, y) {}});").message.contains("setter")); +} + +#[test] +fn property_names_may_repeat() { + for src in ["({a: 1, a: 2});", "({a: 1, 'a': 2});", "({get a() {}, set a(v) {}});", "({a: 1, get a() {}});", "({get a() {}, get a() {}});"] { + ok(src); + ok(&format!("'use strict'; {src}")); + } +} + +#[test] +fn escaped_get_and_set_do_not_start_accessors() { + assert!(err("({g\\u0065t a() {}});").message.contains("expected ':'")); + assert!(err("({\\u0073et a(v) {}});").message.contains("expected ':'")); + ok("({g\\u0065t: 1});"); +} + +#[test] +fn member_names_may_be_reserved_words() { + ok("a.if.var.function = b.true;"); + ok("a.\\u0076ar = 1;"); +} + +// ---- statements ---- + +#[test] +fn else_if_chains_nest_as_alternates() { + let p = ok("if (a) x; else if (b) y; else z;"); + let Stmt::If { alternate: Some(alt), .. } = stmt(&p, p.body[0]) else { panic!() }; + let Stmt::If { alternate: Some(last), .. } = stmt(&p, *alt) else { panic!() }; + assert!(matches!(stmt(&p, *last), Stmt::Expr(_))); +} + +#[test] +fn for_statements_of_every_form() { + ok("for (;;) break;"); + ok("for (var i = 0, n = a.length; i < n; i++) {}"); + ok("for (i = 0; i < 10; i++) continue;"); + ok("for (var k in o) {}"); + ok("for (k in o) {}"); + ok("for (a.b in o) {}"); + ok("for (var x = 1 in o) {}"); + ok("for (var i = (a in b); i; ) break;"); + let p = ok("for (var k in o) {}"); + assert!(matches!(stmt(&p, p.body[0]), Stmt::ForIn { target: ForInTarget::Var(_), .. })); + assert!(err("for (a + b in o) {}").message.contains("for-in")); + assert!(err("for (var a, b in o) {}").message.contains("expected ';'")); +} + +#[test] +fn switch_try_with_labels_and_debugger() { + ok("switch (x) { case 1: a(); break; default: b(); case 2: }"); + ok("try { a(); } catch (e) { b(); } finally { c(); }"); + ok("try {} finally {}"); + ok("with (o) { a = 1; }"); + ok("outer: for (;;) { inner: for (;;) { continue outer; } }"); + ok("a: b: while (1) { continue a; }"); + ok("a: { break a; }"); + ok("debugger;"); + ok("do x(); while (a) y();"); + assert!(err("switch (x) { default: default: }").message.contains("default")); + assert!(err("try {}").message.contains("catch or finally")); +} + +#[test] +fn break_and_continue_need_a_target() { + assert!(err("break;").message.contains("break outside")); + assert!(err("continue;").message.contains("continue outside")); + assert!(err("switch (x) { case 1: continue; }").message.contains("continue outside")); + assert!(err("for (;;) { break nowhere; }").message.contains("undefined label")); + assert!(err("a: { continue a; }").message.contains("does not name a loop")); + assert!(err("a: while (1) { function f() { break a; } }").message.contains("undefined label")); + assert!(err("while (1) { function f() { break; } }").message.contains("break outside")); + assert!(err("a: a: ;").message.contains("duplicate label")); + assert!(err("a: { a: ; }").message.contains("duplicate label")); + ok("a: ; a: ;"); +} + +#[test] +fn return_only_inside_functions() { + assert!(err("return 1;").message.contains("return outside")); + ok("function f() { return; }"); + ok("(function () { return 1 })();"); +} + +#[test] +fn function_declarations_in_blocks_and_branches() { + ok("{ function f() {} }"); + ok("if (a) function f() {} else function g() {}"); + ok("switch (a) { case 1: function f() {} }"); + ok("l: function f() {}"); + ok("{ l: m: function f() {} }"); + ok("'use strict'; { function f() {} }"); + assert!(strict_err("if (a) function f() {}").message.contains("functions can only be declared")); + assert!(strict_err("l: function f() {}").message.contains("functions can only be declared")); + for body in ["while (a) function f() {}", "for (;;) function f() {}", "do function f() {} while (a)", "with (a) function f() {}"] { + assert!(err(body).message.contains("cannot stand here"), "{body}"); + } + assert!(err("if (a) l: function f() {}").message.contains("cannot stand here")); + assert!(err("while (a) l: function f() {}").message.contains("cannot stand here")); +} + +// ---- automatic semicolon insertion ---- + +#[test] +fn semicolons_are_inserted_at_line_breaks_braces_and_the_end() { + ok("a = 1\nb = 2"); + ok("function f() { return 1 }"); + ok("a = 1"); + let e = err("a = 1 b = 2"); + assert_eq!((e.line, e.column), (1, 7)); +} + +#[test] +fn restricted_productions_end_at_a_line_break() { + let p = ok("function f() { return\n1 }"); + let Stmt::FunctionDecl(f) = stmt(&p, p.body[0]) else { panic!() }; + let body = &p.ast.function(*f).unwrap().body; + assert_eq!(body.len(), 2); + assert!(matches!(stmt(&p, body[0]), Stmt::Return(None))); + + let p = ok("a\n++b"); + assert_eq!(p.body.len(), 2); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(_))); + + ok("for (;;) { break\nfoo }"); + assert!(err("throw\nnew Error()").message.contains("line break after throw")); +} + +#[test] +fn no_semicolon_is_inserted_inside_a_for_header() { + assert!(err("for (a\nb\n) {}").message.contains("expected ';'")); +} + +// ---- the division and regular expression goals ---- + +#[test] +fn slash_divides_after_a_value_and_starts_a_regexp_elsewhere() { + let p = ok("a / b / c;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Binary { op: BinaryOp::Div, .. })); + let p = ok("x = /a\\/b[/]c/gi;"); + let Expr::Assign { value, .. } = expr(&p, first_expr(&p)) else { panic!() }; + let Expr::RegExp { pattern, flags } = expr(&p, *value) else { panic!() }; + assert_eq!(*pattern, utf16("a\\/b[/]c")); + assert_eq!(flags, "gi"); + ok("if (x) /re/.test(y);"); + ok("{} /re/.test(y);"); + ok("x = y /= 2;"); + ok("x = /=a/;"); + ok("return_ = typeof /x/;"); + ok("a(/x/, /y/);"); + // After a line break a `/` still divides when it can: there is no semicolon to insert. + let p = ok("a = b\n/c/g"); + assert_eq!(p.body.len(), 1); +} + +#[test] +fn tokenize_reads_each_slash_as_the_grammar_decides() { + let tokens = tokenize("a = b / c; d = /e/g.exec(f);", &Options::default()).unwrap(); + let kinds: Vec<&TokenKind> = tokens.iter().map(|t| &t.kind).collect(); + assert!(kinds.iter().any(|k| matches!(k, TokenKind::RegExp { flags, .. } if flags == "g"))); + assert_eq!(kinds.iter().filter(|k| matches!(k, TokenKind::RegExp { .. })).count(), 1); + assert!(matches!(kinds.last(), Some(TokenKind::Eof))); +} + +// ---- literals ---- + +fn number(src: &str) -> f64 { + let p = ok(src); + let Expr::Number(n) = expr(&p, first_expr(&p)) else { panic!("{src}") }; + *n +} + +fn string(src: &str) -> Vec { + let p = ok(src); + let Expr::String(s) = expr(&p, first_expr(&p)) else { panic!("{src}") }; + s.clone() +} + +#[test] +fn numeric_literals() { + assert_eq!(number("0;"), 0.0); + assert_eq!(number("12.5e-1;"), 1.25); + assert_eq!(number(".5;"), 0.5); + assert_eq!(number("5.;"), 5.0); + assert_eq!(number("1E3;"), 1000.0); + assert_eq!(number("0x1F;"), 31.0); + assert_eq!(number("0XfF;"), 255.0); + assert_eq!(number("0x20000000000001;"), 9007199254740992.0); + assert_eq!(number("017;"), 15.0); + assert_eq!(number("08;"), 8.0); + assert_eq!(number("09.5;"), 9.5); + assert_eq!(number("1e400;"), f64::INFINITY); + assert!(err("3in []").message.contains("immediately after numeric literal")); + assert!(err("0x;").message.contains("hexadecimal")); + assert!(err("1.e").message.contains("immediately after")); + assert!(strict_err("017;").message.contains("octal literals")); + assert!(strict_err("08;").message.contains("octal literals")); + assert!(strict_err("({017: 1});").message.contains("octal")); +} + +#[test] +fn string_literals_and_escapes() { + assert_eq!(string(r#""a\tb\n\\\"\'";"#), utf16("a\tb\n\\\"'")); + assert_eq!(string(r#"'\x41é€';"#), utf16("A\u{e9}\u{20ac}")); + assert_eq!(string("'a\\\nb';"), utf16("ab")); + assert_eq!(string("'a\\\r\nb';"), utf16("ab")); + assert_eq!(string(r"'\0';"), vec![0]); + assert_eq!(string(r"'\101\7\08';"), vec![0x41, 7, 0, u16::from(b'8')]); + assert_eq!(string(r"'\q\8';"), utf16("q8")); + assert_eq!(string("'\u{2028}';"), vec![0x2028]); + assert_eq!(string("'\u{1F600}';"), utf16("\u{1F600}")); + assert!(err("'abc").message.contains("unterminated string")); + assert!(err("'a\nb'").message.contains("unterminated string")); + assert!(err(r"'\x4';").message.contains("hexadecimal escape")); + assert!(err(r"'\u12';").message.contains("Unicode escape")); + assert_eq!(string(r"'\u{41}\u{1F600}\u{0000E9}';"), utf16("A\u{1F600}\u{e9}")); + for bad in [r"'\u{}';", r"'\u{110000}';", r"'\u{41';", r"'\u{4g}';"] { + assert!(err(bad).message.contains("Unicode escape"), "{bad}"); + } + assert!(strict_err(r"'\101';").message.contains("octal escape")); + assert!(strict_err(r"'\8';").message.contains("octal escape")); + ok(r"'use strict'; '\0';"); +} + +#[test] +fn identifiers_with_unicode_and_escapes() { + let p = ok("var caf\\u00e9 = 1, \u{3b1}\u{3b2} = 2, $_x1 = 3, a\u{300} = 4;"); + let Stmt::Var(decls) = stmt(&p, p.body[0]) else { panic!() }; + let names: Vec<&str> = decls.iter().map(|d| d.name.name.as_str()).collect(); + assert_eq!(names, ["caf\u{e9}", "\u{3b1}\u{3b2}", "$_x1", "a\u{300}"]); + let p = ok("\\u{61}b;"); + assert!(matches!(expr(&p, first_expr(&p)), Expr::Ident(n) if n == "ab")); + assert!(err("var \\u0076ar = 1;").message.contains("escaped characters")); + assert!(err("var a\\u0020b;").message.contains("identifier escape")); + assert!(err("var \\x41;").message.contains("escape")); + assert!(err("var a = 1 @ 2;").message.contains("unexpected character '@'")); + assert!(err("var \u{2e2f};").message.contains("unexpected character")); + assert!(err("var a\u{2e2f};").message.contains("unexpected character")); +} + +#[test] +fn identifier_characters_follow_their_unicode_categories() { + // Letters (Lu, Ll, Lt, Lm, Lo, Nl) start an identifier; marks (Mn, Mc), decimal digits (Nd) + // and connector punctuation (Pc) may follow. + for good in [ + "var \u{995}\u{9cd}\u{9b7} = 1;", + "var \u{b95}\u{bcd}\u{b95} = 1;", + "var a\u{9be} = 1;", + "var x\u{966} = 1;", + "var a\u{203f}b = 1;", + "var \u{2b0} = 1;", + "var \u{2160} = 1;", + "var \u{1c5} = 1;", + "var \u{5d0}\u{5b7} = 1;", + ] { + ok(good); + } + // Other numbers (No) are not identifier characters, and a mark cannot start an identifier. + for bad in ["var a\u{b2} = 1;", "var x\u{bd} = 1;", "var \u{9be}a = 1;", "var \u{300}a = 1;", "var \u{966} = 1;"] { + assert!(err(bad).message.contains("unexpected character"), "{bad:?}"); + } + assert!(parse("var \\u09BEa = 1;", &Options::default()).is_err()); + assert!(parse("var a\\u00B2 = 1;", &Options::default()).is_err()); +} + +#[test] +fn comments_and_line_terminators() { + let p = ok("a /* one\ntwo */ b"); + assert_eq!(p.body.len(), 2, "a comment with a line break separates statements"); + assert!(err("a /* one two */ b").message.contains("unexpected identifier 'b'")); + assert!(err("/* open").message.contains("unterminated comment")); + ok("// only a comment"); + ok("a\u{2028}b\u{2029}c\r\nd\re"); + ok("\u{feff}var\u{a0}a\u{3000}=\u{b}1\u{c};"); +} + +// ---- regular expression literals ---- + +#[test] +fn regular_expression_flags_and_patterns_are_checked() { + for good in [ + "/a|b*c+?d{2}e{1,}f{1,3}?/", + "/[a-z\\d-]/", + "/[\\d-z]/", + "/]/", + "/{/", + "/a{,}/", + "/x{a}/", + "/(?:a)(?=b)(?!c)/", + "/(?=a)*/", + "/\\1(a)/", + "/[]/", + "/[^]/", + "/\\cJ\\c/", + "/[\\b-\\n]/", + "/a/gim", + ] { + ok(&format!("x = {good};")); + } + for (bad, message) in [ + ("/(/", "unterminated group"), + ("/a)/", "unmatched"), + ("/?/", "nothing to repeat"), + ("/a**/", "nothing to repeat"), + ("/^*/", "nothing to repeat"), + ("/{1}/", "nothing to repeat"), + ("/a{2,1}/", "out of order"), + ("/[z-a]/", "out of order"), + ("/(?x)/", "invalid group"), + ("/[a/", "unterminated regular expression"), + ("/a\\/", "unterminated regular expression"), + ("/a\n/", "unterminated regular expression"), + ("/a/gg", "flags"), + ("/a/x", "flags"), + ("/a/\\u0067", "flags"), + ] { + let e = err(&format!("x = {bad};")); + assert!(e.message.contains(message), "{bad}: {}", e.message); + assert_eq!((e.line, e.column), (1, 5), "{bad}"); + } +} + +// ---- strict mode ---- + +#[test] +fn use_strict_makes_the_rest_strict_mode_code() { + assert!(ok("'use strict'; a;").strict); + assert!(ok("\"use strict\"\na;").strict); + assert!(!ok("'use\\x20strict'; a;").strict, "a directive with an escape is not \"use strict\""); + assert!(!ok("('use strict'); a;").strict); + assert!(!ok("a; 'use strict';").strict); + assert!(ok("'other'; 'use strict'; a;").strict); + assert!(!ok("'use strict' + 1;").strict); + let p = ok("function f() { 'use strict'; } function g() {}"); + let funcs = &p.ast.functions; + assert!(funcs[0].strict && !funcs[1].strict && !p.strict); + assert!(parse("with (a) {}", &Options { strict: true, ..Options::default() }).is_err()); +} + +#[test] +fn strict_mode_early_errors() { + assert!(strict_err("with (a) {}").message.contains("with statements")); + assert!(strict_err("delete a;").message.contains("delete")); + assert!(strict_err("delete ((a));").message.contains("delete")); + ok("'use strict'; delete a.b; delete a[0];"); + for target in ["eval = 1;", "arguments++;", "--eval;", "arguments += 1;", "(eval) = 1;", "for (eval in o) {}"] { + assert!(strict_err(target).message.contains("cannot assign"), "{target}"); + } + for binding in ["var eval;", "function arguments() {}", "function f(eval) {}", "try {} catch (arguments) {}", "(function eval() {});"] { + assert!(strict_err(binding).message.contains("cannot be declared"), "{binding}"); + } + for word in ["implements", "interface", "let", "package", "private", "protected", "public", "static", "yield"] { + ok(&format!("var {word} = 1;")); + assert!(strict_err(&format!("var {word} = 1;")).message.contains("reserved word"), "{word}"); + assert!(strict_err(&format!("{word};")).message.contains("reserved word"), "{word}"); + } + assert!(strict_err("function f(a, a) {}").message.contains("duplicate parameter")); + ok("function f(a, a) {}"); + let e = strict_err("for (var x = 1 in o) {}"); + assert!(e.message.contains("initializer"), "{}", e.message); + assert_eq!((e.line, e.column), (2, 16)); + ok("'use strict'; for (var x in o) {}"); +} + +#[test] +fn a_function_made_strict_by_its_own_prologue_checks_its_name_and_parameters() { + let e = err("function f(a, a) { 'use strict'; }"); + assert!(e.message.contains("duplicate parameter")); + assert_eq!((e.line, e.column), (1, 15)); + assert!(err("function eval() { 'use strict'; }").message.contains("cannot be declared")); + assert!(err("function f(arguments) { 'use strict'; }").message.contains("cannot be declared")); + assert!(err("function static() { 'use strict'; }").message.contains("reserved word")); + assert!(err("function f() { '\\01'; 'use strict'; }").message.contains("octal escape")); + assert!(err("'\\01'; 'use strict';").message.contains("octal escape")); + assert!(err("function f() { 'use strict'; 010 }").message.contains("octal literals")); + assert!(err("function f() { 'use strict'; var public; }").message.contains("reserved word")); +} + +// ---- assignment targets ---- + +#[test] +fn invalid_assignment_targets() { + for bad in ["1 = 2;", "a + b = c;", "(a, b) = 1;", "a = 1 = 2;", "++1;", "1++;", "--a.b++;", "this = 1;", "'a' += 1;", "(a = 1) = 2;"] { + let e = err(bad); + assert!(e.message.contains("invalid"), "{bad}: {}", e.message); + } + ok("(a) = 1; a.b = 1; a[0] += 2; a.b++; --a[1]; (a.b) = c;"); + // Assigning to a call's result fails at run time outside strict mode code, and is a + // SyntaxError in it. + ok("f() = 1; f()++; for (f() in o) {}"); + for call in ["f() = 1;", "f() += 1;", "++f();", "f()--;", "(f()) = 1;", "for (f() in o) {}"] { + assert!(strict_err(call).message.contains("invalid") || strict_err(call).message.contains("for-in"), "{call}"); + } +} + +// ---- preprocessor lines ---- + +#[test] +fn preprocessor_lines_are_read_as_directives() { + let src = "#target vectorcraft\n #include \"lib/a.jsxinc\"\n//@includepath \"~/x;~/y\"\n#strict on\n#unknown x\nvar a = 1;\n#targetengine main"; + let p = parse(src, &with_preprocessor()).unwrap(); + let names: Vec<(&str, u32, &str)> = p.directives.iter().map(|d| (d.name.as_str(), d.line, d.value.as_str())).collect(); + assert_eq!( + names, + [ + ("target", 1, "vectorcraft"), + ("include", 2, "\"lib/a.jsxinc\""), + ("includepath", 3, "\"~/x;~/y\""), + ("strict", 4, "on"), + ("unknown", 5, "x"), + ("targetengine", 7, "main"), + ] + ); + assert_eq!(p.directives[2].form, "//@"); + assert!(p.directives[4].known.is_none()); + assert_eq!(p.body.len(), 1); + assert!(parse(src, &Options::default()).is_err(), "without preprocessor lines a # is an error"); +} + +#[test] +fn a_hash_away_from_the_start_of_a_line_is_an_error() { + let e = parse("var a = 1; #include x", &with_preprocessor()).unwrap_err(); + assert!(e.message.contains("unexpected character '#'")); + assert_eq!((e.line, e.column), (1, 12)); + assert!(parse("/* c */ #include x", &with_preprocessor()).is_err()); + // A comment naming no directive stays a comment. + assert!(parse("//@other\n", &with_preprocessor()).unwrap().directives.is_empty()); +} + +#[test] +fn a_directive_between_tokens_keeps_its_place_in_the_token_list() { + let tokens = tokenize("a\n#include x\nb", &with_preprocessor()).unwrap(); + let kinds: Vec = tokens + .iter() + .map(|t| match &t.kind { + TokenKind::Ident(n) => n.clone(), + TokenKind::Directive(d) => format!("#{}", d.name), + TokenKind::Eof => "eof".into(), + other => format!("{other:?}"), + }) + .collect(); + assert_eq!(kinds, ["a", "#include", "b", "eof"]); + assert!(tokens[2].newline_before); +} + +// ---- positions ---- + +#[test] +fn errors_carry_line_and_column() { + let e = err("var a = 1;\nvar b = ];"); + assert_eq!((e.line, e.column, e.offset), (2, 9, 19)); + assert_eq!(e.to_string(), "SyntaxError: unexpected token ']' (line 2, column 9)"); + let e = err("a\r\nb\r\n 'x"); + assert_eq!((e.line, e.column), (3, 3)); + let e = err("a\u{2028} )"); + assert_eq!((e.line, e.column), (2, 3)); + let e = err("'\u{1F600}' )"); + assert_eq!((e.line, e.column), (1, 6), "columns count UTF-16 code units"); + let e = err("x = 'a\\\nb' )"); + assert_eq!((e.line, e.column), (2, 4), "a line continuation in a string counts its line"); +} + +#[test] +fn line_count_and_spans() { + let p = ok("a;\r\nb;\nc;"); + assert_eq!(p.line_count(), 3); + assert_eq!(ok("a;\n").line_count(), 1); + assert_eq!(ok("").line_count(), 0); + let span = p.ast.stmt_span(p.body[2]); + assert_eq!(p.line_column(span.start), (3, 1)); + assert_eq!((span.start, span.end), (7, 9)); +} + +// ---- nesting and limits ---- + +#[test] +fn deep_nesting_gives_a_syntax_error_not_a_stack_overflow() { + const N: usize = 100_000; + let cases = [ + format!("{}1{}", "(".repeat(N), ")".repeat(N)), + format!("{}1{}", "[".repeat(N), "]".repeat(N)), + format!("x = {}{}", "{a:".repeat(N), "}".repeat(N)), + format!("{}{}", "{".repeat(N), "}".repeat(N)), + format!("{}{}", "function f(){".repeat(N), "}".repeat(N)), + format!("{}1", "a ? b : ".repeat(N)), + format!("{}x{}", "f(".repeat(N), ")".repeat(N)), + format!("{}x", "if (a) ".repeat(N)), + ]; + for src in &cases { + let e = err(src); + assert_eq!(e.message, TOO_DEEP, "{}", &src[..20]); + assert_eq!(e.line, 1); + assert!(e.column > 1); + } + let e = err(&format!("x = /{}a{}/;", "(".repeat(N), ")".repeat(N))); + assert!(e.message.contains(TOO_DEEP)); +} + +#[test] +fn nesting_up_to_the_limit_parses() { + let depth = 50; + ok(&format!("{}1{};", "(".repeat(depth), ")".repeat(depth))); + ok(&format!("{}{}", "{".repeat(depth), "}".repeat(depth))); + ok(&format!("x = {}{};", "[".repeat(depth), "]".repeat(depth))); +} + +#[test] +fn long_flat_runs_do_not_nest() { + const N: usize = 100_000; + ok(&format!("x = 1{};", " + 1".repeat(N))); + ok(&format!("x = {}y;", "!".repeat(N))); + ok(&format!("{}x = 1;", "a = ".repeat(N))); + ok(&format!("if (a) b;{} else c;", " else if (a) b;".repeat(N))); + ok(&format!("{}x;", (0..N).map(|i| format!("l{i}: ")).collect::())); + ok(&format!("x{};", ".y".repeat(N))); + ok(&format!("x{};", "()".repeat(N))); + ok(&"a;\n".repeat(N)); +} + +#[test] +fn long_label_chains_and_parameter_lists_are_checked_in_linear_time() { + // Each check looks a name up in a hash set or map. Comparing every name with every earlier + // one would take about 2 * 10^10 steps at this size. + const N: usize = 200_000; + let labels: String = (0..N).map(|i| format!("l{i}: ")).collect(); + let body: String = (0..N).map(|i| format!("m{i}: break l{i}; ")).collect(); + ok(&format!("{labels}for (;;) {{ {body}continue l0; }}")); + assert!(err(&format!("{labels}l0: x;")).message.contains("duplicate label")); + assert!(err(&format!("{labels}for (;;) {{ m: break l{N}; }}")).message.contains("undefined label")); + let params = (0..N).map(|i| format!("p{i}")).collect::>().join(", "); + ok(&format!("function f({params}) {{ 'use strict'; }}")); + assert!(err(&format!("function f({params}, p0) {{ 'use strict'; }}")).message.contains("duplicate parameter")); +} + +#[test] +fn nesting_to_the_default_limit_fits_a_small_stack() { + // The deepest nesting each production allows, parsed on a thread with a 1 MiB stack (the + // stack a wasm build or a secondary thread may have). + let n = crate::DEFAULT_MAX_DEPTH; + let cases = [ + format!("x = {}1{};", "(".repeat(n), ")".repeat(n)), + format!("x = {}1{};", "[".repeat(n), "]".repeat(n)), + format!("x = {}1{};", "{a:".repeat(n), "}".repeat(n)), + format!("{}{}", "function f(){".repeat(n), "}".repeat(n)), + format!("{}x;", "if (a) ".repeat(n)), + format!("x = {}1;", "a ? b : ".repeat(n)), + format!("{}x{};", "f(".repeat(n), ")".repeat(n)), + format!("{}x{};", "a[".repeat(n), "]".repeat(n)), + ]; + let handle = std::thread::Builder::new() + .stack_size(1 << 20) + .spawn(move || cases.iter().map(|src| parse(src, &Options::default()).map(|_| ()).map_err(|e| e.message)).collect::>()) + .unwrap(); + for result in handle.join().unwrap() { + // Each either parses or stops at the limit; neither overflows the stack. + assert!(result.is_ok() || result == Err(TOO_DEEP.to_string()), "{result:?}"); + } +} + +#[test] +fn sources_over_the_length_limit_are_refused() { + let options = Options { max_source_units: 10, ..Options::default() }; + assert_eq!(parse("a = 1;", &options).map(|_| ()), Ok(())); + assert_eq!(parse("a = 1 + 2 + 3;", &options).unwrap_err().message, crate::TOO_LONG); + assert_eq!(parse("'\u{e9}\u{e9}\u{e9}\u{e9}';", &options).map(|_| ()), Ok(()), "the limit counts UTF-16 code units"); +} + +// ---- robustness ---- + +/// Fragments of the language, put together at random so the parser reaches its deeper states. +const FRAGMENTS: &[&str] = &[ + "var ", + "function ", + "f", + "(", + ")", + "{", + "}", + "[", + "]", + ";", + ",", + "=", + "+", + "-", + "/", + "/=", + "*", + "?", + ":", + "!", + "++", + "--", + "if", + "else", + "for", + "in", + "while", + "do", + "return", + "break", + "continue", + "new", + "delete", + "typeof", + "this", + "with", + "switch", + "case", + "default", + "try", + "catch", + "finally", + "throw", + "get", + "set", + "'s'", + "\"\\x4", + "1", + "0x", + "017", + ".5", + "1e", + "/re/g", + "a:", + "\n", + " ", + "'use strict';", + "#include x\n", + "//c\n", + "/*", + "*/", + "\\u0061", + "\u{2028}", + "eval", + "arguments", + ".", + "x", +]; + +proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] + + #[test] + fn arbitrary_text_never_panics(src in ".{0,200}") { + let _ = parse(&src, &Options::default()); + let _ = parse(&src, &Options { strict: true, preprocessor_lines: true, ..Options::default() }); + } + + #[test] + fn arbitrary_fragments_never_panic(parts in proptest::collection::vec(0..FRAGMENTS.len(), 0..80)) { + let src: String = parts.iter().map(|&i| FRAGMENTS[i]).collect(); + for options in [Options::default(), Options { strict: true, preprocessor_lines: true, max_depth: 8, ..Options::default() }] { + match parse(&src, &options) { + Ok(p) => { + for (_, span) in &p.ast.exprs { + prop_assert!(span.start <= span.end && span.end as usize <= src.encode_utf16().count()); + } + } + Err(e) => prop_assert!(e.line >= 1 && e.column >= 1), + } + } + } + + #[test] + fn arbitrary_utf16_never_panics(units in proptest::collection::vec(any::(), 0..120)) { + let _ = crate::parse_utf16(&units, &Options::default()); + } +} diff --git a/crates/js/src/token.rs b/crates/js/src/token.rs new file mode 100644 index 000000000..53bbb35af --- /dev/null +++ b/crates/js/src/token.rs @@ -0,0 +1,391 @@ +//! Tokens: what the lexer gives the parser. + +/// A range of the source in UTF-16 code units, `start` included and `end` not. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Span { + pub start: u32, + pub end: u32, +} + +impl Span { + pub fn new(start: u32, end: u32) -> Self { + Self { start, end } + } + + /// The span from the start of `self` to the end of `other`. + pub fn to(self, other: Span) -> Span { + Span { start: self.start, end: other.end.max(self.start) } + } +} + +/// The reserved words of ECMA-262 5.1 section 7.6.1 that are reserved in all code, with the +/// literals `null`, `true` and `false`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Keyword { + Break, + Case, + Catch, + Class, + Const, + Continue, + Debugger, + Default, + Delete, + Do, + Else, + Enum, + Export, + Extends, + False, + Finally, + For, + Function, + If, + Import, + In, + Instanceof, + New, + Null, + Return, + Super, + Switch, + This, + Throw, + True, + Try, + Typeof, + Var, + Void, + While, + With, +} + +impl Keyword { + /// The keyword spelled `s`, if `s` is one. + pub fn from_word(s: &str) -> Option { + use Keyword as K; + Some(match s { + "break" => K::Break, + "case" => K::Case, + "catch" => K::Catch, + "class" => K::Class, + "const" => K::Const, + "continue" => K::Continue, + "debugger" => K::Debugger, + "default" => K::Default, + "delete" => K::Delete, + "do" => K::Do, + "else" => K::Else, + "enum" => K::Enum, + "export" => K::Export, + "extends" => K::Extends, + "false" => K::False, + "finally" => K::Finally, + "for" => K::For, + "function" => K::Function, + "if" => K::If, + "import" => K::Import, + "in" => K::In, + "instanceof" => K::Instanceof, + "new" => K::New, + "null" => K::Null, + "return" => K::Return, + "super" => K::Super, + "switch" => K::Switch, + "this" => K::This, + "throw" => K::Throw, + "true" => K::True, + "try" => K::Try, + "typeof" => K::Typeof, + "var" => K::Var, + "void" => K::Void, + "while" => K::While, + "with" => K::With, + _ => return None, + }) + } + + pub fn as_str(self) -> &'static str { + use Keyword as K; + match self { + K::Break => "break", + K::Case => "case", + K::Catch => "catch", + K::Class => "class", + K::Const => "const", + K::Continue => "continue", + K::Debugger => "debugger", + K::Default => "default", + K::Delete => "delete", + K::Do => "do", + K::Else => "else", + K::Enum => "enum", + K::Export => "export", + K::Extends => "extends", + K::False => "false", + K::Finally => "finally", + K::For => "for", + K::Function => "function", + K::If => "if", + K::Import => "import", + K::In => "in", + K::Instanceof => "instanceof", + K::New => "new", + K::Null => "null", + K::Return => "return", + K::Super => "super", + K::Switch => "switch", + K::This => "this", + K::Throw => "throw", + K::True => "true", + K::Try => "try", + K::Typeof => "typeof", + K::Var => "var", + K::Void => "void", + K::While => "while", + K::With => "with", + } + } +} + +/// The words reserved only in strict mode code (ECMA-262 5.1 section 7.6.1.2). +pub fn strict_reserved(name: &str) -> bool { + matches!(name, "implements" | "interface" | "let" | "package" | "private" | "protected" | "public" | "static" | "yield") +} + +/// The punctuators of ECMA-262 5.1 sections 7.7 and 7.8.5 (division). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Punct { + LBrace, + RBrace, + LParen, + RParen, + LBracket, + RBracket, + Dot, + Semicolon, + Comma, + Lt, + Gt, + LtEq, + GtEq, + EqEq, + NotEq, + EqEqEq, + NotEqEq, + Plus, + Minus, + Star, + Percent, + PlusPlus, + MinusMinus, + Shl, + Shr, + UShr, + Amp, + Pipe, + Caret, + Bang, + Tilde, + AmpAmp, + PipePipe, + Question, + Colon, + Eq, + PlusEq, + MinusEq, + StarEq, + PercentEq, + ShlEq, + ShrEq, + UShrEq, + AmpEq, + PipeEq, + CaretEq, + Slash, + SlashEq, +} + +impl Punct { + /// Punctuators by spelling, longest first, so the first that matches is the longest. + pub(crate) const ALL: &'static [(&'static str, Punct)] = &[ + (">>>=", Punct::UShrEq), + ("===", Punct::EqEqEq), + ("!==", Punct::NotEqEq), + (">>>", Punct::UShr), + ("<<=", Punct::ShlEq), + (">>=", Punct::ShrEq), + ("<=", Punct::LtEq), + (">=", Punct::GtEq), + ("==", Punct::EqEq), + ("!=", Punct::NotEq), + ("++", Punct::PlusPlus), + ("--", Punct::MinusMinus), + ("<<", Punct::Shl), + (">>", Punct::Shr), + ("&&", Punct::AmpAmp), + ("||", Punct::PipePipe), + ("+=", Punct::PlusEq), + ("-=", Punct::MinusEq), + ("*=", Punct::StarEq), + ("%=", Punct::PercentEq), + ("&=", Punct::AmpEq), + ("|=", Punct::PipeEq), + ("^=", Punct::CaretEq), + ("/=", Punct::SlashEq), + ("{", Punct::LBrace), + ("}", Punct::RBrace), + ("(", Punct::LParen), + (")", Punct::RParen), + ("[", Punct::LBracket), + ("]", Punct::RBracket), + (".", Punct::Dot), + (";", Punct::Semicolon), + (",", Punct::Comma), + ("<", Punct::Lt), + (">", Punct::Gt), + ("+", Punct::Plus), + ("-", Punct::Minus), + ("*", Punct::Star), + ("%", Punct::Percent), + ("&", Punct::Amp), + ("|", Punct::Pipe), + ("^", Punct::Caret), + ("!", Punct::Bang), + ("~", Punct::Tilde), + ("?", Punct::Question), + (":", Punct::Colon), + ("=", Punct::Eq), + ("/", Punct::Slash), + ]; + + pub fn as_str(self) -> &'static str { + Punct::ALL.iter().find(|(_, p)| *p == self).map_or("", |(s, _)| s) + } +} + +/// A preprocessor line's directive: `#include`, `#includepath`, `#script`, `#strict`, `#target` +/// and `#targetengine`, as the public JavaScript Tools Guide lists them +/// (). +/// Only the names are taken from it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum DirectiveName { + Include, + IncludePath, + Script, + Strict, + Target, + TargetEngine, +} + +impl DirectiveName { + /// The directive named `name`, compared without case. + pub fn from_name(name: &str) -> Option { + const NAMES: [(&str, DirectiveName); 6] = [ + ("include", DirectiveName::Include), + ("includepath", DirectiveName::IncludePath), + ("script", DirectiveName::Script), + ("strict", DirectiveName::Strict), + ("target", DirectiveName::Target), + ("targetengine", DirectiveName::TargetEngine), + ]; + NAMES.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|(_, d)| *d) + } + + pub fn as_str(self) -> &'static str { + match self { + DirectiveName::Include => "include", + DirectiveName::IncludePath => "includepath", + DirectiveName::Script => "script", + DirectiveName::Strict => "strict", + DirectiveName::Target => "target", + DirectiveName::TargetEngine => "targetengine", + } + } +} + +/// A preprocessor line: `#name value` at the start of a line, or a `//@name value` comment there +/// that names a directive. It is read and kept, never run. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Directive { + /// The directive, when the name is one of the six (`#name` lines with another name are kept + /// with `None`). + pub known: Option, + /// The name as written. + pub name: String, + /// The text after the name, with blanks around it removed. + pub value: String, + /// `#` or `//@`. + pub form: &'static str, + pub span: Span, + pub line: u32, +} + +/// What a token is. +#[derive(Clone, Debug, PartialEq)] +pub enum TokenKind { + /// The end of the source. + Eof, + /// An identifier, the strict-mode reserved words included. `escaped` tokens held a `\u` escape. + Ident(String), + Keyword(Keyword), + Punct(Punct), + /// A numeric literal's value. + Number(f64), + /// A string literal's value in UTF-16 code units. + String(Vec), + /// A regular expression literal: its pattern and flags as written. + RegExp { + pattern: Vec, + flags: String, + }, + /// A preprocessor line. + Directive(Directive), +} + +/// A token, with where it is and the facts the grammar's rules need about it. +#[derive(Clone, Debug, PartialEq)] +pub struct Token { + pub kind: TokenKind, + pub span: Span, + /// The line (from 1) and column (from 1, in UTF-16 code units) the token starts at. + pub line: u32, + pub column: u32, + /// A line terminator comes between the previous token and this one (automatic semicolon + /// insertion and the restricted productions use it). + pub newline_before: bool, + /// An identifier written with a `\u` escape. + pub escaped: bool, + /// A legacy octal numeric literal (`017`, `08`) or a string with an octal escape (`"\1"`), which + /// strict mode code does not allow. + pub legacy_octal: bool, +} + +impl Token { + pub fn is_punct(&self, p: Punct) -> bool { + self.kind == TokenKind::Punct(p) + } + + pub fn is_keyword(&self, k: Keyword) -> bool { + self.kind == TokenKind::Keyword(k) + } + + /// The identifier's name, for an identifier token. + pub fn ident(&self) -> Option<&str> { + match &self.kind { + TokenKind::Ident(name) => Some(name), + _ => None, + } + } + + /// The name this token gives when it stands where any identifier name may, as a property's + /// name after `.` or in an object literal: identifiers, keywords and the literals `null`, + /// `true` and `false`. + pub fn identifier_name(&self) -> Option<&str> { + match &self.kind { + TokenKind::Ident(name) => Some(name), + TokenKind::Keyword(k) => Some(k.as_str()), + _ => None, + } + } +} diff --git a/crates/js/tests/test262.rs b/crates/js/tests/test262.rs new file mode 100644 index 000000000..c32045507 --- /dev/null +++ b/crates/js/tests/test262.rs @@ -0,0 +1,178 @@ +//! The ES5 parse subset of test262, the ECMAScript conformance suite, when `cargo xtask test262` +//! has fetched it (it sets `VECTORCRAFT_TEST262_DIR`; without it the test looks in +//! `corpus/test262/` and skips when the suite is absent). +//! +//! The subset is every test under `test/language/` that names the ES5 section it was written for +//! (`es5id:`) and has no `features:` tag (the tags name syntax and built-ins later than ES5) and no +//! `module` flag. The other tests without a feature tag (written for later editions, many of them +//! using later syntax) run too, and their counts are printed apart. A test whose metadata says +//! `negative: phase: parse` must give a `SyntaxError`; every other test must parse. Each test runs +//! as sloppy and as strict mode code (with `"use strict";` before it, as the suite's +//! `INTERPRETING.md` says), unless its `onlyStrict`, `noStrict` or `raw` flag says otherwise. The +//! test prints the counts and writes each failure to `failures.txt` beside the suite; it fails only +//! when the suite cannot be read. + +use std::path::{Path, PathBuf}; + +use vectorcraft_js::{Options, parse}; + +/// A test file's metadata, from its `/*--- … ---*/` block. +#[derive(Default)] +struct Meta { + features: Vec, + flags: Vec, + negative_phase: Option, + /// The test names the ES5 section it was written for (`es5id:`). + es5: bool, +} + +fn suite_dir() -> Option { + if let Ok(dir) = std::env::var("VECTORCRAFT_TEST262_DIR") { + return Some(PathBuf::from(dir)); + } + let corpus = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/test262"); + let mut dirs: Vec = std::fs::read_dir(corpus).ok()?.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.is_dir()).collect(); + dirs.sort(); + dirs.pop() +} + +/// The `.js` files under `dir`, sorted, with the `_FIXTURE` helper files left out. +fn test_files(dir: &Path) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&d) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|e| e == "js") && !path.to_string_lossy().contains("_FIXTURE") { + out.push(path); + } + } + } + out.sort(); + out +} + +/// A YAML flow list (`[a, b]`) or the block list (`- a` lines) after a key. +fn list(value: &str, lines: &mut std::iter::Peekable>) -> Vec { + let value = value.trim(); + if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) { + return inner.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + } + let mut out = Vec::new(); + while let Some(item) = lines.peek().and_then(|l| l.trim().strip_prefix("- ")) { + out.push(item.trim().to_string()); + lines.next(); + } + out +} + +fn meta(source: &str) -> Meta { + let mut meta = Meta::default(); + let Some(block) = source.split_once("/*---").and_then(|(_, rest)| rest.split_once("---*/")).map(|(block, _)| block) else { + return meta; + }; + let mut lines = block.lines().peekable(); + let mut in_negative = false; + while let Some(line) = lines.next() { + let indented = line.starts_with(' '); + let trimmed = line.trim(); + if !indented { + in_negative = false; + } + if let Some(v) = trimmed.strip_prefix("features:").filter(|_| !indented) { + meta.features = list(v, &mut lines); + } else if let Some(v) = trimmed.strip_prefix("flags:").filter(|_| !indented) { + meta.flags = list(v, &mut lines); + } else if trimmed.starts_with("es5id:") && !indented { + meta.es5 = true; + } else if trimmed == "negative:" && !indented { + in_negative = true; + } else if let Some(v) = trimmed.strip_prefix("phase:").filter(|_| in_negative) { + meta.negative_phase = Some(v.trim().to_string()); + } + } + meta +} + +#[test] +fn es5_parse_subset() { + let Some(dir) = suite_dir() else { + println!("test262: SKIPPED (no suite; run cargo xtask test262)"); + return; + }; + let language = dir.join("test").join("language"); + assert!(language.is_dir(), "{} is not a test262 checkout", dir.display()); + let files = test_files(&language); + assert!(!files.is_empty(), "no tests under {}", language.display()); + // VECTORCRAFT_TEST262_VERBOSE=1 prints each test before it is parsed. + let verbose = std::env::var_os("VECTORCRAFT_TEST262_VERBOSE").is_some(); + + let (mut positive, mut negative, mut with_features, mut modules) = (0usize, 0usize, 0usize, 0usize); + let (mut passed, mut failed) = (0usize, 0usize); + // The tests without a feature tag that name no ES5 section: files, runs passed and failed. + let (mut later, mut later_passed, mut later_failed) = (0usize, 0usize, 0usize); + let mut failures = String::new(); + let mut later_failures = String::new(); + for path in &files { + let source = String::from_utf8_lossy(&std::fs::read(path).unwrap()).into_owned(); + let meta = meta(&source); + if !meta.features.is_empty() { + with_features += 1; + continue; + } + if meta.flags.iter().any(|f| f == "module") || meta.negative_phase.as_deref() == Some("resolution") { + modules += 1; + continue; + } + let expect_error = meta.negative_phase.as_deref() == Some("parse"); + if !meta.es5 { + later += 1; + } else if expect_error { + negative += 1; + } else { + positive += 1; + } + let has = |flag: &str| meta.flags.iter().any(|f| f == flag); + let modes: &[bool] = if has("raw") || has("noStrict") { + &[false] + } else if has("onlyStrict") { + &[true] + } else { + &[false, true] + }; + for &strict in modes { + if verbose { + eprintln!("{} ({})", path.display(), if strict { "strict" } else { "sloppy" }); + } + let text = if strict { format!("\"use strict\";\n{source}") } else { source.clone() }; + let result = parse(&text, &Options::default()); + let (pass, fail, list) = + if meta.es5 { (&mut passed, &mut failed, &mut failures) } else { (&mut later_passed, &mut later_failed, &mut later_failures) }; + if result.is_err() == expect_error { + *pass += 1; + } else { + *fail += 1; + let rel = path.strip_prefix(&dir).unwrap_or(path).display().to_string().replace('\\', "/"); + let mode = if strict { "strict" } else { "sloppy" }; + let outcome = match &result { + Ok(_) => "parsed; a SyntaxError was expected".to_string(), + Err(e) => e.to_string(), + }; + list.push_str(&format!("{rel} ({mode}): {outcome}\n")); + } + } + } + let report = dir.join("failures.txt"); + std::fs::write(&report, format!("# ES5 subset\n{failures}\n# Other tests without a feature tag\n{later_failures}")).unwrap(); + println!( + "test262 ES5 parse subset ({}): {} tests ({positive} that must parse, {negative} that must give a SyntaxError), \ + {passed} runs passed, {failed} failed. Other tests without a feature tag: {later} tests, {later_passed} runs passed, \ + {later_failed} failed. Skipped {with_features} tests with feature tags and {modules} module tests. Failures: {}", + dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(), + positive + negative, + report.display() + ); +} diff --git a/crates/pathops/src/boolean.rs b/crates/pathops/src/boolean.rs index 6fa499467..3a9aaaec8 100644 --- a/crates/pathops/src/boolean.rs +++ b/crates/pathops/src/boolean.rs @@ -255,12 +255,60 @@ pub(crate) struct Arrangement { pub(crate) struct Tidy { pub(crate) precision: f64, keep: Vec, + /// `keep` by y, sorted on first use. + keep_by_y: std::sync::OnceLock>, /// Input curves (with inflated bounds) used to rebuild split pieces exactly. curves: Vec<(kurbo::Rect, CubicBez)>, /// `curves` indices by the left edge of their bounds, and the widest bounds: the curves whose /// bounds can hold a point, without testing them all. by_x: Vec, max_width: f64, + /// The top edge of their bounds and their index, in order of that edge, with the tallest + /// bounds, made on first use. + by_y: std::sync::OnceLock<(Vec<(f64, usize)>, f64)>, + /// The wide lookups in `curves` and in `keep` so far. + curve_lookups: WideLookups, + point_lookups: WideLookups, +} + +/// The lookups in one list of [`Tidy`] with more than [`WIDE_WINDOW`] candidates along x: the +/// candidates they counted, a score that rises by [`Y_WIN`] when the window along y was the one +/// used and falls by one when it was not (within [`Y_SCORE_LIMIT`] either way), and the lookups +/// made while the score was below zero. +#[derive(Debug, Default)] +struct WideLookups { + scanned: std::sync::atomic::AtomicUsize, + score: std::sync::atomic::AtomicIsize, + below: std::sync::atomic::AtomicUsize, +} + +impl Clone for WideLookups { + fn clone(&self) -> Self { + let get = |n: &std::sync::atomic::AtomicUsize| n.load(std::sync::atomic::Ordering::Relaxed).into(); + Self { scanned: get(&self.scanned), score: self.score().into(), below: get(&self.below) } + } +} + +impl WideLookups { + fn score(&self) -> isize { + self.score.load(std::sync::atomic::Ordering::Relaxed) + } + /// Does a lookup with `window` candidates along x measure the window along y? It does past + /// [`WIDE_WINDOW`] candidates, once wide lookups have scanned [`SORT_COST`] candidates for each + /// of the `len` items that the sort along y orders, while the score is zero or more, and in + /// one of every [`Y_RETRY`] lookups while it is below. + fn search_y(&self, window: usize, len: usize) -> bool { + use std::sync::atomic::Ordering::Relaxed; + window > WIDE_WINDOW + && self.scanned.fetch_add(window, Relaxed) + window >= len.saturating_mul(SORT_COST) + && (self.score() >= 0 || self.below.fetch_add(1, Relaxed) % Y_RETRY == Y_RETRY - 1) + } + /// Counts a lookup that measured along y, and whether that window was the one used. + fn used_y(&self, used: bool) { + let step = if used { Y_WIN } else { -1 }; + let next = (self.score() + step).clamp(-Y_SCORE_LIMIT, Y_SCORE_LIMIT); + self.score.store(next, std::sync::atomic::Ordering::Relaxed); + } } impl Tidy { @@ -286,12 +334,46 @@ impl Tidy { let mut by_x: Vec = (0..curves.len()).collect(); by_x.sort_by(|&i, &j| curves[i].0.x0.total_cmp(&curves[j].0.x0)); let max_width = curves.iter().map(|(r, _)| r.width()).fold(0.0, f64::max); - Self { precision, keep, curves, by_x, max_width } + Self { precision, keep, curves, by_x, max_width, ..Default::default() } } + fn keep_by_y(&self) -> &[Point] { + self.keep_by_y.get_or_init(|| { + let mut keep = self.keep.clone(); + keep.sort_unstable_by(|a, b| a.y.total_cmp(&b.y)); + keep + }) + } + fn by_y(&self) -> (&[(f64, usize)], f64) { + let (by_y, max_height) = self.by_y.get_or_init(|| { + let mut by_y: Vec<(f64, usize)> = self.curves.iter().enumerate().map(|(i, (r, _))| (r.y0, i)).collect(); + by_y.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + (by_y, self.curves.iter().map(|(r, _)| r.height()).fold(0.0, f64::max)) + }); + (by_y, *max_height) + } + /// Is a kept point within the tolerance of `p`? The points within it along x are tested, or + /// those along y when [`WideLookups::search_y`] holds and there are fewer along y. fn is_kept(&self, p: Point) -> bool { let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); let lo = self.keep.partition_point(|q| q.x < p.x - tol); - self.keep[lo..].iter().take_while(|q| q.x <= p.x + tol).any(|q| (q.y - p.y).abs() <= tol) + // Usually one or two points lie within the tolerance along x; count them one by one up to + // the wide window, and search past it. + let rest = &self.keep[lo..]; + let mut within = rest.iter().take(WIDE_WINDOW + 1).take_while(|q| q.x <= p.x + tol).count(); + if within > WIDE_WINDOW { + within = rest.partition_point(|q| q.x <= p.x + tol); + } + let hi = lo + within; + if self.point_lookups.search_y(hi - lo, self.keep.len()) { + let keep_by_y = self.keep_by_y(); + let lo_y = keep_by_y.partition_point(|q| q.y < p.y - tol); + let hi_y = lo_y + keep_by_y[lo_y..].partition_point(|q| q.y <= p.y + tol); + self.point_lookups.used_y(hi_y - lo_y < hi - lo); + if hi_y - lo_y < hi - lo { + return keep_by_y[lo_y..hi_y].iter().any(|q| (q.x - p.x).abs() <= tol); + } + } + self.keep[lo..hi].iter().any(|q| (q.y - p.y).abs() <= tol) } } @@ -579,13 +661,52 @@ fn exact_merge(run: &[Seg], tidy: &Tidy) -> Option { None } +/// Past this many candidates along x, a lookup in [`Tidy`] adds them to the count of scanned ones. +const WIDE_WINDOW: usize = 64; + +/// The candidates scanned per item of a list before [`Tidy`] sorts that list along y: about the +/// comparisons per item of sorting 100,000 items. +const SORT_COST: usize = 16; + +/// How much a lookup that used the window along y raises the score in [`WideLookups`]. +const Y_WIN: isize = 4; + +/// The highest and the lowest score in [`WideLookups`] (negated). +const Y_SCORE_LIMIT: isize = 256; + +/// While the score in [`WideLookups`] is below zero, one lookup in this many measures along y. +const Y_RETRY: usize = 32; + impl Tidy { - /// The input curves whose bounds hold both `a` and `b`. + /// The input curves whose bounds hold both `a` and `b`, in the order of `by_x`. The candidates + /// are the curves whose bounds start within the widest bounds' reach of the points along x, or + /// the same along y when [`WideLookups::search_y`] holds and there are under a quarter as many + /// along y. fn curves_around(&self, a: Point, b: Point) -> impl Iterator { + let holds = move |(r, _): &&(kurbo::Rect, CubicBez)| r.contains(a) && r.contains(b); let x = a.x.min(b.x); let hi = self.by_x.partition_point(|&i| self.curves[i].0.x0 <= x); let lo = self.by_x[..hi].partition_point(|&i| self.curves[i].0.x0 < x - self.max_width); - self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(move |(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c) + let along_y = self.curve_lookups.search_y(hi - lo, self.curves.len()).then(|| { + let (by_y, max_height) = self.by_y(); + let y = a.y.min(b.y); + let hi_y = by_y.partition_point(|&(y0, _)| y0 <= y); + let lo_y = by_y[..hi_y].partition_point(|&(y0, _)| y0 < y - max_height); + &by_y[lo_y..hi_y] + }); + let along_y = along_y.filter(|ys| { + let used = ys.len().saturating_mul(4) < hi - lo; + self.curve_lookups.used_y(used); + used + }); + let along_y = along_y.map(|ys| { + let mut found: Vec<(usize, &(kurbo::Rect, CubicBez))> = ys.iter().map(|&(_, i)| (i, &self.curves[i])).filter(|(_, c)| holds(c)).collect(); + // `by_x` was sorted stably from index order, so this puts them in its order. + found.sort_by(|(i, p), (j, q)| p.0.x0.total_cmp(&q.0.x0).then(i.cmp(j))); + found.into_iter().map(|(_, c)| c) + }); + let along_x = along_y.is_none().then(|| self.by_x[lo..hi].iter().map(|&i| &self.curves[i]).filter(holds)); + along_x.into_iter().flatten().chain(along_y.into_iter().flatten()).map(|(_, c)| c) } /// `bp` with each curve piece that strays from the one input curve through both its ends @@ -821,6 +942,17 @@ pub fn try_unite_all(paths: &[(&PathData, FillRule)]) -> Result Result { + match crate::sweep_axis::turned_fills(paths) { + Some(turned) => { + let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect(); + try_unite_all_upright(&refs, precision).map(crate::sweep_axis::unturn) + } + None => try_unite_all_upright(paths, precision), + } +} + +/// [`try_unite_all_with`] with the sweeps run on `paths` as they are. +pub(crate) fn try_unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> Result { let mut all = BezPath::new(); for (p, r) in paths { let bp = fill_bezpath(p); @@ -846,6 +978,17 @@ pub fn unite_all(paths: &[(&PathData, FillRule)]) -> PathData { /// [`unite_all`] refit to `precision`, dropping slivers thinner than it. pub fn unite_all_with(paths: &[(&PathData, FillRule)], precision: f64) -> PathData { + match crate::sweep_axis::turned_fills(paths) { + Some(turned) => { + let refs: Vec<(&PathData, FillRule)> = turned.iter().map(|(p, r)| (p, *r)).collect(); + crate::sweep_axis::unturn(unite_all_upright(&refs, precision)) + } + None => unite_all_upright(paths, precision), + } +} + +/// [`unite_all_with`] with the sweeps run on `paths` as they are. +pub(crate) fn unite_all_upright(paths: &[(&PathData, FillRule)], precision: f64) -> PathData { let mut all = BezPath::new(); for (p, r) in paths { let bp = fill_bezpath(p); @@ -920,6 +1063,108 @@ mod tests { assert_eq!(tidy.repair(&on), on); } + #[test] + fn input_curves_and_kept_points_are_found_in_wide_and_tall_art() { + // Rows of short curves across the page, and the same stood on end: one lookup has a + // narrow window along x, the other along y. + let mut wide = BezPath::new(); + for row in 0..4 { + let y = row as f64 * 3.0; + wide.move_to((0.0, y)); + for k in 0..200 { + let x = k as f64; + wide.curve_to((x + 0.3, y + 1.0), (x + 0.7, y + 1.0), (x + 1.0, y)); + } + } + let tall = kurbo::Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0]) * &wide; + for art in [&wide, &tall] { + let tidy = Tidy::keeping(DEFAULT_PRECISION, [art]); + let mut sorted: Vec = (0..tidy.curves.len()).collect(); + sorted.sort_by(|&i, &j| tidy.curves[i].0.x0.total_cmp(&tidy.curves[j].0.x0).then(i.cmp(&j))); + for (_, c) in tidy.curves.iter().step_by(7) { + use kurbo::ParamCurve; + // A curve's start point is also held by the curve before it, whose bounds start a + // whole curve further back. + for (a, b) in [(c.p0, c.p3), (c.p0, c.p0), (c.eval(0.25), c.eval(0.75)), (c.p0, c.p0 + kurbo::Vec2::new(2.5, 2.5))] { + let by_hand: Vec<&CubicBez> = + sorted.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a) && r.contains(b)).map(|(_, c)| c).collect(); + assert_eq!(tidy.curves_around(a, b).collect::>(), by_hand, "{a:?} {b:?}"); + } + for p in [c.p0, c.p0 + kurbo::Vec2::new(1e-7, -1e-7), c.p0 + kurbo::Vec2::new(0.0, 1e-3), c.p0 + kurbo::Vec2::new(1e-3, 0.0)] { + let tol = 1e-6 * (1.0 + p.x.abs().max(p.y.abs())); + let by_hand = tidy.keep.iter().any(|q| (q.x - p.x).abs() <= tol && (q.y - p.y).abs() <= tol); + assert_eq!(tidy.is_kept(p), by_hand, "{p:?}"); + } + } + // The tall art's lookups reach the sorted lists along y; the wide art's never do. + let tall_art = std::ptr::eq(art, &tall); + assert_eq!(tidy.by_y.get().is_some(), tall_art); + assert_eq!(tidy.keep_by_y.get().is_some(), tall_art); + } + } + + #[test] + fn a_few_wide_lookups_do_not_sort_along_y() { + let mut tall = BezPath::new(); + for x in 0..2 { + let x = f64::from(x) * 3.0; + tall.move_to((x, 0.0)); + for k in 0..200 { + let y = f64::from(k); + tall.curve_to((x + 1.0, y + 0.3), (x + 1.0, y + 0.7), (x, y + 1.0)); + } + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&tall]); + let a = Point::new(0.0, 50.0); + let found = |tidy: &Tidy| tidy.curves_around(a, a).count(); + // Bounds at x = 0: the column's 200 curves are candidates along x, and two of them hold `a`. + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + assert!(tidy.by_y.get().is_none()); + assert!(tidy.keep_by_y.get().is_none()); + // Enough of them to pay for the sort, and the answers stay the same. + let scanned = |l: &WideLookups| l.scanned.load(std::sync::atomic::Ordering::Relaxed); + while scanned(&tidy.curve_lookups) < SORT_COST * tidy.curves.len() || scanned(&tidy.point_lookups) < SORT_COST * tidy.keep.len() { + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + } + assert_eq!(found(&tidy), 2); + assert!(tidy.is_kept(a)); + assert!(tidy.by_y.get().is_some()); + assert!(tidy.keep_by_y.get().is_some()); + // Along y the windows are narrow, so the lookups keep measuring there. + assert!(tidy.curve_lookups.score() > 0 && tidy.point_lookups.score() > 0); + } + + #[test] + fn lookups_measure_along_y_rarely_where_it_narrows_nothing() { + // A square grid of short curves: about as many candidates lie along y as along x. + let mut grid = BezPath::new(); + for row in 0..100 { + let y = f64::from(row) * 2.0; + grid.move_to((0.0, y)); + for k in 0..100 { + let x = f64::from(k); + grid.curve_to((x + 0.3, y + 0.5), (x + 0.7, y + 0.5), (x + 1.0, y)); + } + } + let tidy = Tidy::keeping(DEFAULT_PRECISION, [&grid]); + let lookups: u32 = 20_000; + for k in 0..lookups { + let a = Point::new(f64::from(k % 97) + 0.5, f64::from(k % 89) * 2.0 + 0.2); + let found: Vec<&CubicBez> = tidy.curves_around(a, a).collect(); + if k % 500 == 0 { + let by_hand: Vec<&CubicBez> = tidy.by_x.iter().map(|&i| &tidy.curves[i]).filter(|(r, _)| r.contains(a)).map(|(_, c)| c).collect(); + assert_eq!(found, by_hand, "{a:?}"); + } + } + // The window along y was measured until the score fell below zero, then once in Y_RETRY lookups. + assert!(tidy.by_y.get().is_some()); + assert_eq!(tidy.curve_lookups.score(), -Y_SCORE_LIMIT); + let below = tidy.curve_lookups.below.load(std::sync::atomic::Ordering::Relaxed); + assert!(below > lookups as usize / 2, "{below} of {lookups} lookups below zero"); + } + #[test] fn a_lens_between_two_curves_through_the_same_points_stays() { // Two input curves meeting at both ends bound a real face: neither piece is "repaired" diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 3d1b17fef..b7af34c89 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -14,6 +14,7 @@ mod fit; mod offset; mod pathfinder; mod planar; +mod sweep_axis; mod trap; pub use boolean::{ @@ -26,7 +27,7 @@ pub use edit::{ split_into_grid, }; pub use offset::{Cap, Join, MAX_OFFSET_SEGMENT_PAIRS, offset_path, outline_stroke, stroke_region, try_offset_path}; -pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions}; +pub use pathfinder::{FaceMerger, PathfinderOp, Region, Shape, merge_regions, pathfinder, pathfinder_with, region_at, regions, regions_within}; pub use planar::{ BuilderArrangement, SHAPE_BUILDER_MAX_GAP, SHAPE_BUILDER_MAX_SEGMENTS, cut_out, encloses_area, find_gaps, has_open_path, interior_point, live_paint, live_paint_with_gaps, run_around, shape_builder, shape_builder_with_gap, diff --git a/crates/pathops/src/offset.rs b/crates/pathops/src/offset.rs index 2c0db1f2a..01c3d03cb 100644 --- a/crates/pathops/src/offset.rs +++ b/crates/pathops/src/offset.rs @@ -85,6 +85,14 @@ pub fn outline_stroke(path: &PathData, width: f64, cap: Cap, join: Join, miter_l /// The area covered by stroke outlines (each filled with the non-zero rule, open subpaths /// closed) as one clean path: overlaps removed and the stroker's pieces refitted. pub fn stroke_region(outlines: &[BezPath]) -> PathData { + match crate::sweep_axis::turned_outlines(outlines) { + Some(turned) => crate::sweep_axis::unturn(stroke_region_upright(&turned)), + None => stroke_region_upright(outlines), + } +} + +/// [`stroke_region`] with the sweep run on `outlines` as they are. +pub(crate) fn stroke_region_upright(outlines: &[BezPath]) -> PathData { let tidy = Tidy::free(DEFAULT_PRECISION); let parts: Vec = outlines.iter().map(close_all).filter(|o| !o.elements().is_empty()).collect(); if let [one] = &parts[..] { diff --git a/crates/pathops/src/pathfinder.rs b/crates/pathops/src/pathfinder.rs index f0c5ab505..d6d85c9ad 100644 --- a/crates/pathops/src/pathfinder.rs +++ b/crates/pathops/src/pathfinder.rs @@ -2,6 +2,7 @@ //! (index 0 = back-most, last = front-most). use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; use kurbo::{BezPath, Line, ParamCurve, ParamCurveNearest, PathSeg, Point, Shape as _}; use linesweeper::topology::{ContourIdx, Contours}; @@ -12,6 +13,12 @@ use crate::boolean::{ to_seg, unite_all, unite_all_with, }; +#[cfg(test)] +thread_local! { + /// The face outlines [`regions_of_within`] has built on this thread. + static FACE_PATHS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// A filled shape in a Pathfinder stack. `key` identifies its paint (e.g. a hashed fill colour); /// results carry the key of the object whose paint they keep. #[derive(Clone, Debug, PartialEq)] @@ -221,20 +228,41 @@ pub fn pathfinder_with(op: PathfinderOp, shapes: &[Shape], precision: f64) -> Ve /// The faces of `arr`, each distinct coverage mask's in turn (sorted), each face an outer contour /// and the holes of its mask directly inside it ([`face_groups`]). fn regions_of(arr: &Arrangement) -> Vec { + regions_of_within(arr, usize::MAX).unwrap_or_default() +} + +/// [`regions_of`] while there are at most `limit` faces: `None` as soon as one more is found, +/// without building the faces after it. +fn regions_of_within(arr: &Arrangement, limit: usize) -> Option> { let masks = arr.distinct_masks(); let refs: Vec<&[bool]> = masks.iter().map(Vec::as_slice).collect(); let mut faces: Vec> = vec![Vec::new(); masks.len()]; - each_face(arr, &refs, |m, c, group| { - if let Some(f) = faces.get_mut(m) { - f.push(contours_to_path(c, group.iter().copied(), &arr.tidy)); + let mut found = 0usize; + let walk = each_face_until(arr, &refs, |m, c, group| { + #[cfg(test)] + FACE_PATHS.with(|n| n.set(n.get() + 1)); + let path = contours_to_path(c, group.iter().copied(), &arr.tidy); + if path.is_empty() { + return ControlFlow::Continue(()); } + found += 1; + if found > limit { + return ControlFlow::Break(()); + } + if let Some(f) = faces.get_mut(m) { + f.push(path); + } + ControlFlow::Continue(()) }); + if walk.is_break() { + return None; + } let mut out = Vec::new(); for (mask, paths) in masks.iter().zip(faces) { let sources: Vec = mask.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect(); - out.extend(paths.into_iter().filter(|p| !p.is_empty()).map(|path| Region { path, sources: sources.clone() })); + out.extend(paths.into_iter().map(|path| Region { path, sources: sources.clone() })); } - out + Some(out) } /// Calls `f` with each face of `arr` classified by `classify: impl Fn(&Multi) -> Option` @@ -251,8 +279,21 @@ fn each_classified_face( classify: impl Fn(&Multi) -> Option, mut f: impl FnMut(usize, &Contours, &[ContourIdx]), ) { + let _ = each_classified_face_until(arr, num_classes, classify, |m, c, group| { + f(m, c, group); + ControlFlow::Continue(()) + }); +} + +/// [`each_classified_face`] until `f` breaks: no face after that one is walked. +fn each_classified_face_until( + arr: &Arrangement, + num_classes: usize, + classify: impl Fn(&Multi) -> Option, + mut f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>, +) -> ControlFlow<()> { if num_classes == 0 { - return; + return ControlFlow::Continue(()); } let top = &arr.top; // The classes of the faces around each vertex. @@ -304,15 +345,20 @@ fn each_classified_face( let c = arr.contours_where(|w| classify(w) == Some(m)); let owner = vec![m; c.contours().count()]; for (_, group) in face_groups(&c, &owner) { - f(m, &c, &group); + if f(m, &c, &group).is_break() { + return ControlFlow::Break(()); + } } } continue; }; for (m, group) in face_groups(&c, &owner) { - f(m, &c, &group); + if f(m, &c, &group).is_break() { + return ControlFlow::Break(()); + } } } + ControlFlow::Continue(()) } /// Calls `f` with each face of `arr` whose coverage is one of `masks` (distinct): the index of @@ -325,13 +371,21 @@ fn each_classified_face( /// share no vertex (so no edge either): one pass per batch walks each face's contours exactly as /// its own mask's pass would, and the mask a contour bounds is the one of its batch at its first /// point. -fn each_face(arr: &Arrangement, masks: &[&[bool]], f: impl FnMut(usize, &Contours, &[ContourIdx])) { +fn each_face(arr: &Arrangement, masks: &[&[bool]], mut f: impl FnMut(usize, &Contours, &[ContourIdx])) { + let _ = each_face_until(arr, masks, |m, c, group| { + f(m, c, group); + ControlFlow::Continue(()) + }); +} + +/// [`each_face`] until `f` breaks: no face after that one is walked. +fn each_face_until(arr: &Arrangement, masks: &[&[bool]], f: impl FnMut(usize, &Contours, &[ContourIdx]) -> ControlFlow<()>) -> ControlFlow<()> { if masks.is_empty() { - return; + return ControlFlow::Continue(()); } // Each mask is found by the inputs it covers. let ids = CoverIds::new(masks.iter().map(|m| m.iter().enumerate().filter(|(_, b)| **b).map(|(k, _)| k).collect()).collect()); - each_classified_face(arr, masks.len(), |w| ids.of(arr, w), f); + each_classified_face_until(arr, masks.len(), |w| ids.of(arr, w), f) } /// Lists of inputs, each ascending as [`Arrangement::covered`] gives them, found by the inputs @@ -463,6 +517,15 @@ pub(crate) fn regions_with(shapes: &[Shape], precision: f64) -> Vec { arrangement(shapes, precision).map(|a| regions_of(&a)).unwrap_or_default() } +/// [`regions`] when there are at most `limit` of them; `None` when there are more, found as soon +/// as the face past `limit` is built (the faces after it are not built). +pub fn regions_within(shapes: &[Shape], limit: usize) -> Option> { + match arrangement(shapes, DEFAULT_PRECISION) { + Some(a) => regions_of_within(&a, limit), + None => Some(Vec::new()), + } +} + /// The face under `point`, if any (Shape Builder hover/click). pub fn region_at(shapes: &[Shape], point: Point) -> Option { regions(shapes).into_iter().find(|r| r.contains(point)) @@ -1273,6 +1336,29 @@ mod tests { assert!((area - 490.0 * 40.0).abs() < 1e-6, "{area}"); } + /// The row of 49 rectangles makes 97 faces: within a limit of 97 or more they are [`regions`]'s + /// faces, and over a lower one the build stops at the face past it. + #[test] + fn regions_within_a_limit_stop_at_the_face_past_it() { + let v: Vec = (0..49) + .map(|k| { + let x = 10.0 * f64::from(k); + Shape::new(rect(x, 0.0, x + 15.0, 40.0), FillRule::NonZero, 1) + }) + .collect(); + let all = regions(&v); + assert_eq!(all.len(), 97); + for limit in [97, 98, usize::MAX] { + assert_eq!(regions_within(&v, limit).as_ref(), Some(&all), "limit {limit}"); + } + for limit in [0, 1, 10, 96] { + FACE_PATHS.with(|n| n.set(0)); + assert_eq!(regions_within(&v, limit), None, "limit {limit}"); + assert_eq!(FACE_PATHS.with(std::cell::Cell::get), limit + 1, "limit {limit}: faces built"); + } + assert_eq!(regions_within(&[], 0), Some(vec![])); + } + /// The lists of inputs a winding covers are found by those inputs, also among lists that share /// a hash: here every list is filed under every hash. #[test] diff --git a/crates/pathops/src/sweep_axis.rs b/crates/pathops/src/sweep_axis.rs new file mode 100644 index 000000000..364325afc --- /dev/null +++ b/crates/pathops/src/sweep_axis.rs @@ -0,0 +1,393 @@ +//! Which way the overlap sweep runs. `linesweeper` moves a horizontal line down the page, and the +//! time it spends at each event grows with the number of segments on that line at the time. A long, +//! low path (rows of a zigzag, a stroked line of type) can put thousands of segments on the line at +//! once, where the same path turned a quarter turn puts far fewer on it. [`turned_outlines`] and +//! [`turned_fills`] estimate both directions before the sweep; when the turned one is clearly +//! cheaper they return the input turned, the caller runs its operation on that, and [`unturn`] +//! turns the result back. A quarter turn swaps and negates coordinates, so no point moves by +//! rounding. + +use kurbo::{Affine, BezPath, PathSeg, Rect}; +use vectorcraft_geom::{Anchor, FillRule, PathData, SubPath}; + +/// Inputs with fewer segments than this are always swept as they are. +const MIN_SEGMENTS: usize = 2048; + +/// The turned direction is used when its estimate is below the upright one divided by this. +const MIN_GAIN: f64 = 1.5; + +/// The most segments the estimate measures. +const MAX_SAMPLE: usize = 4096; + +/// (x, y) to (−y, x). +const TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]); + +/// (x, y) to (y, −x): the inverse of [`TURN`]. +const UNTURN: Affine = Affine::new([0.0, -1.0, 1.0, 0.0, 0.0, 0.0]); + +#[cfg(test)] +thread_local! { + /// The inputs turned on this thread. + pub(crate) static TURNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// For each of the spans from `starts[i]` to `ends[i]`, the number of spans that overlap it +/// (touching counts, itself included), summed: the segments a sweep across these extents has on +/// its line while each one is there. Sorts both lists. +fn load(starts: &mut [f64], ends: &mut [f64]) -> u64 { + starts.sort_unstable_by(f64::total_cmp); + ends.sort_unstable_by(f64::total_cmp); + // Spans that start at or before each end, less spans that end before each start: every span + // that ends before a start also starts before it, so it was counted in the first sum. + let (mut begun, mut i) = (0u64, 0usize); + for &e in ends.iter() { + while starts.get(i).is_some_and(|&s| s <= e) { + i += 1; + } + begun += i as u64; + } + let (mut over, mut j) = (0u64, 0usize); + for &s in starts.iter() { + while ends.get(j).is_some_and(|&e| e < s) { + j += 1; + } + over += j as u64; + } + begun.saturating_sub(over) +} + +/// The segments of `outlines`. +fn outline_segments(outlines: &[BezPath]) -> impl Iterator + '_ { + outlines.iter().flat_map(BezPath::segments) +} + +/// The box of a segment's control points. +fn segment_box(s: PathSeg) -> Rect { + match s { + PathSeg::Line(l) => Rect::from_points(l.p0, l.p1), + PathSeg::Quad(q) => Rect::from_points(q.p0, q.p2).union_pt(q.p1), + PathSeg::Cubic(c) => Rect::from_points(c.p0, c.p3).union_pt(c.p1).union_pt(c.p2), + } +} + +/// The subpaths of `paths` that have a fill: those of two or more anchors. +fn filled_subpaths<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator + 'a { + paths.iter().flat_map(|(p, _)| &p.subpaths).filter(|sp| sp.anchors.len() >= 2) +} + +/// The start and end anchor of each segment in the fills of `paths`, every subpath of two or more +/// anchors closed, as [`fill_bezpath`](crate::boolean::fill_bezpath) builds them. +fn fill_segments<'a>(paths: &'a [(&PathData, FillRule)]) -> impl Iterator + 'a { + filled_subpaths(paths).flat_map(|sp| { + let n = sp.anchors.len(); + sp.anchors.iter().enumerate().map(move |(i, a)| (a, &sp.anchors[(i + 1) % n])) + }) +} + +/// The box of the control points of the fill segment from `a` to `b`. +fn fill_segment_box((a, b): (&Anchor, &Anchor)) -> Rect { + Rect::from_points(a.p, b.p).union_pt(a.h_out).union_pt(b.h_in) +} + +/// Is the sweep over the `count` `segments`, each with the box `bbox` gives it, clearly cheaper +/// turned a quarter turn? Past [`MAX_SAMPLE`] segments, one segment from each run of +/// `count / MAX_SAMPLE` (rounded up) stands for the run, at a position within it that changes from +/// run to run, and only those segments' boxes are built. +fn turn_pays(count: usize, segments: impl Iterator, bbox: impl Fn(T) -> Rect) -> bool { + if count < MIN_SEGMENTS { + return false; + } + let stride = count.div_ceil(MAX_SAMPLE); + let mut pick = 0; + let boxes: Vec = segments + .enumerate() + .filter(|&(i, _)| { + if i % stride == 0 { + pick = sample_offset(i / stride, stride); + } + i % stride == pick + }) + .map(|(_, s)| bbox(s)) + .collect(); + if !boxes.iter().all(Rect::is_finite) { + return false; + } + let (mut x0, mut x1): (Vec, Vec) = boxes.iter().map(|b| (b.x0, b.x1)).unzip(); + let (mut y0, mut y1): (Vec, Vec) = boxes.iter().map(|b| (b.y0, b.y1)).unzip(); + let pays = load(&mut x0, &mut x1) as f64 * MIN_GAIN < load(&mut y0, &mut y1) as f64; + #[cfg(test)] + if pays { + TURNS.with(|n| n.set(n.get() + 1)); + } + pays +} + +/// The position, below `stride`, of the segment measured in run `run`: a hash of `run` (the +/// SplitMix64 finalizer), so input that repeats every few segments is measured at every position. +fn sample_offset(run: usize, stride: usize) -> usize { + let mut z = (run as u64).wrapping_add(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + ((z ^ (z >> 31)) % stride as u64) as usize +} + +/// `outlines` turned a quarter turn, when sweeping them that way is clearly cheaper. +pub(crate) fn turned_outlines(outlines: &[BezPath]) -> Option> { + let count = outlines.iter().map(|o| o.segments().count()).sum(); + turn_pays(count, outline_segments(outlines), segment_box).then(|| outlines.iter().map(|o| TURN * o).collect()) +} + +/// `paths` turned a quarter turn, when sweeping their fills that way is clearly cheaper. +pub(crate) fn turned_fills(paths: &[(&PathData, FillRule)]) -> Option> { + let count = filled_subpaths(paths).map(|sp| sp.anchors.len()).sum(); + turn_pays(count, fill_segments(paths), fill_segment_box).then(|| paths.iter().map(|(p, r)| (p.transformed(TURN), *r)).collect()) +} + +/// A result computed on turned input, turned back. +pub(crate) fn unturn(mut path: PathData) -> PathData { + path.transform(UNTURN); + path +} + +#[cfg(test)] +mod tests { + use kurbo::{Join, Stroke, StrokeOpts}; + use vectorcraft_geom::{Anchor, Point, Vec2}; + + use super::*; + use crate::boolean::{BoolOp, DEFAULT_PRECISION, fill_bezpath, try_area, try_boolean, try_unite_all_upright, unite_all_upright}; + use crate::offset::stroke_region_upright; + use crate::{stroke_region, try_unite_all_with, unite_all_with}; + + /// A small xorshift generator, so the random cases are the same on every run. + struct Rng(u64); + + impl Rng { + fn unit(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 + } + } + + /// `rows` rows of a zigzag `cols` anchors long, each anchor moved by up to `jitter`: a long, low path. + fn zigzag(rows: usize, cols: usize, jitter: f64, rng: &mut Rng, closed: bool) -> PathData { + let mut anchors = Vec::new(); + for row in 0..rows { + for col in 0..cols { + let c = if row % 2 == 0 { col } else { cols - 1 - col }; + let x = c as f64 * 1.6 + (rng.unit() - 0.5) * jitter; + let y = row as f64 * 6.0 + if col % 2 == 0 { 0.0 } else { 2.0 } + (rng.unit() - 0.5) * jitter; + let p = Point::new(x, y); + let dir = if row % 2 == 0 { 0.5 } else { -0.5 }; + anchors.push(Anchor::smooth(p, p + Vec2::new(dir, 0.0))); + } + } + PathData::single(SubPath::new(anchors, closed)) + } + + /// The same zigzag stood on end: a tall, narrow path. + fn upright(p: &PathData) -> PathData { + p.transformed(Affine::new([0.0, 1.0, 1.0, 0.0, 0.0, 0.0])) + } + + /// A unit square with its lower left corner at (`x`, `y`). + fn square(x: f64, y: f64) -> PathData { + let corners = [(x, y), (x + 1.0, y), (x + 1.0, y + 1.0), (x, y + 1.0)]; + PathData::single(SubPath::new(corners.map(|(x, y)| Anchor::corner(Point::new(x, y))).to_vec(), true)) + } + + fn outline(p: &PathData) -> BezPath { + kurbo::stroke(p.to_bezpath().iter(), &Stroke::new(1.0).with_join(Join::Round), &StrokeOpts::default(), 1e-3) + } + + fn segments(p: &BezPath) -> usize { + p.segments().count() + } + + fn turns_during(f: impl FnOnce()) -> usize { + let before = TURNS.with(std::cell::Cell::get); + f(); + TURNS.with(std::cell::Cell::get) - before + } + + /// `a` and `b` have the same pieces and cover the same area to within the refit precision: the + /// area in one and not the other is less than a band [`DEFAULT_PRECISION`] wide along the outline. + fn assert_same_region(a: &PathData, b: &PathData, what: &str) { + use kurbo::Shape; + assert_eq!(a.subpaths.len(), b.subpaths.len(), "{what}: subpaths"); + let band = DEFAULT_PRECISION * a.to_bezpath().perimeter(1e-3); + let area_a = try_area(a, FillRule::NonZero).unwrap(); + let area_b = try_area(b, FillRule::NonZero).unwrap(); + assert!((area_a - area_b).abs() <= band, "{what}: area {area_a} against {area_b}, band {band}"); + let apart = try_boolean(a, FillRule::NonZero, b, FillRule::NonZero, BoolOp::Xor, DEFAULT_PRECISION).unwrap(); + let apart = try_area(&apart, FillRule::NonZero).unwrap(); + assert!(apart <= band, "{what}: {apart} of {area_a} differs, band {band}"); + } + + #[test] + fn a_long_low_path_is_swept_turned_and_a_tall_or_small_one_as_it_is() { + let mut rng = Rng(7); + let low = zigzag(3, 300, 0.0, &mut rng, false); + let stroke = outline(&low); + // More segments than the estimate measures, so it decides from a sample. + assert!(segments(&stroke) > MAX_SAMPLE, "{} segments", segments(&stroke)); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&stroke)))), 1); + let fill = zigzag(3, 800, 0.0, &mut rng, true); + assert_eq!(turns_during(|| drop(unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1); + assert_eq!(turns_during(|| drop(try_unite_all_with(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION))), 1); + // Stood on end, the same paths are already narrow across the sweep. + let tall = outline(&upright(&low)); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&tall)))), 0); + assert_eq!(turns_during(|| drop(unite_all_with(&[(&upright(&fill), FillRule::NonZero)], DEFAULT_PRECISION))), 0); + // A square grid costs the same either way. + let grid: Vec = (0..900).map(|i| square((i % 30) as f64 * 3.0, (i / 30) as f64 * 3.0)).collect(); + let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert_eq!(turns_during(|| drop(unite_all_with(&grid, DEFAULT_PRECISION))), 0); + // Below the size where the sweep costs much, nothing is turned. + let short = outline(&zigzag(1, 40, 0.0, &mut rng, false)); + assert!(segments(&short) < MIN_SEGMENTS); + assert_eq!(turns_during(|| drop(stroke_region(std::slice::from_ref(&short)))), 0); + } + + #[test] + fn a_repeating_path_is_measured_at_every_position() { + // Short segments in a row along the bottom, with a long one stacked above the row after + // every `period - 1` of them. The short ones alone are cheaper to sweep turned; with the + // long ones, the path is cheaper upright. + let add = |path: &mut BezPath, a: (f64, f64), b: (f64, f64)| { + path.move_to(a); + path.line_to(b); + }; + for period in [2, 4] { + let n = MAX_SAMPLE; + let width = (n * period) as f64 * 3.0; + let mut both = BezPath::new(); + let mut shorts = BezPath::new(); + for i in 0..n { + for k in 0..period - 1 { + let x = (i * period + k) as f64 * 3.0; + add(&mut both, (x, 0.0), (x + 1.0, 0.0)); + add(&mut shorts, (x, 0.0), (x + 1.0, 0.0)); + } + add(&mut both, (0.0, 10.0 + i as f64), (width, 10.0 + i as f64)); + } + assert_eq!(segments(&both), period * MAX_SAMPLE, "period {period}"); + assert!(turned_outlines(&[shorts]).is_some(), "period {period}: shorts"); + assert!(turned_outlines(&[both]).is_none(), "period {period}: both"); + } + } + + #[test] + fn the_load_counts_every_overlapping_pair() { + let mut rng = Rng(5); + for n in [0, 1, 2, 7, 60] { + // Coarse values, so many spans share ends and touch. + let spans: Vec<(f64, f64)> = (0..n) + .map(|_| { + let a = (rng.unit() * 10.0).round(); + let b = (rng.unit() * 10.0).round(); + (a.min(b), a.max(b)) + }) + .collect(); + let by_hand = spans.iter().map(|&(lo, hi)| spans.iter().filter(|&&(s, e)| s <= hi && e >= lo).count() as u64).sum::(); + let (mut starts, mut ends): (Vec, Vec) = spans.iter().copied().unzip(); + assert_eq!(load(&mut starts, &mut ends), by_hand, "{n} spans"); + } + } + + #[test] + fn fill_boxes_are_the_boxes_of_the_fills_segments() { + let mut rng = Rng(9); + let open = zigzag(2, 30, 1.0, &mut rng, false); + let closed = zigzag(1, 20, 1.0, &mut rng, true); + let lone = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(4.0, 4.0))], false)); + let paths = [(&open, FillRule::NonZero), (&closed, FillRule::EvenOdd), (&lone, FillRule::NonZero)]; + let fills: Vec = paths.iter().map(|(p, _)| fill_bezpath(p)).collect(); + assert_eq!(fill_segments(&paths).map(fill_segment_box).collect::>(), outline_segments(&fills).map(segment_box).collect::>()); + assert_eq!(filled_subpaths(&paths).map(|sp| sp.anchors.len()).sum::(), fills.iter().map(|f| f.segments().count()).sum::()); + } + + #[test] + fn turning_and_turning_back_moves_no_point() { + let mut rng = Rng(11); + let p = zigzag(2, 50, 3.0, &mut rng, true); + assert_eq!(unturn(p.transformed(TURN)), p); + let bp = outline(&p); + assert_eq!(UNTURN * (TURN * &bp), bp); + } + + #[test] + fn turned_results_match_the_upright_sweep_on_random_paths() { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + for case in 0..3 { + let rows = 2 + case; + let low = zigzag(rows, 120 + 40 * case, 0.8, &mut rng, false); + let stroke = [outline(&low)]; + assert!(turned_outlines(&stroke).is_some(), "case {case}: not turned"); + assert_same_region(&stroke_region(&stroke), &stroke_region_upright(&stroke), &format!("case {case}: stroke")); + let fill = zigzag(rows, 1100, 0.8, &mut rng, true); + let one = [(&fill, FillRule::NonZero)]; + assert!(turned_fills(&one).is_some(), "case {case}: fill not turned"); + assert_same_region(&unite_all_with(&one, DEFAULT_PRECISION), &unite_all_upright(&one, DEFAULT_PRECISION), &format!("case {case}: unite")); + assert_same_region( + &try_unite_all_with(&one, DEFAULT_PRECISION).unwrap(), + &try_unite_all_upright(&one, DEFAULT_PRECISION).unwrap(), + &format!("case {case}: try_unite"), + ); + } + } + + #[test] + fn tall_and_ordinary_art_give_exactly_the_upright_result() { + let mut rng = Rng(13); + let tall = [outline(&upright(&zigzag(3, 300, 0.4, &mut rng, false)))]; + assert_eq!(stroke_region(&tall), stroke_region_upright(&tall), "tall stroke"); + let tall_fill = upright(&zigzag(3, 800, 0.4, &mut rng, true)); + let one = [(&tall_fill, FillRule::NonZero)]; + assert_eq!(unite_all_with(&one, DEFAULT_PRECISION), unite_all_upright(&one, DEFAULT_PRECISION), "tall unite"); + // A square grid of overlapping squares, and a small path below the size that is measured. + let grid: Vec = (0..900).map(|i| square((i % 30) as f64 * 0.75, (i / 30) as f64 * 0.75)).collect(); + let grid: Vec<(&PathData, FillRule)> = grid.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert_eq!(unite_all_with(&grid, DEFAULT_PRECISION), unite_all_upright(&grid, DEFAULT_PRECISION), "grid"); + assert_eq!(try_unite_all_with(&grid, DEFAULT_PRECISION).unwrap(), try_unite_all_upright(&grid, DEFAULT_PRECISION).unwrap(), "grid, try"); + let small = [outline(&zigzag(1, 40, 0.4, &mut rng, false))]; + assert_eq!(stroke_region(&small), stroke_region_upright(&small), "small"); + } + + #[test] + fn edge_cases_give_the_upright_result() { + // Nothing to sweep. + assert!(turned_outlines(&[]).is_none()); + assert!(stroke_region(&[]).is_empty()); + assert!(unite_all_with(&[], DEFAULT_PRECISION).is_empty()); + // A single segment. + let line = BezPath::from_vec(vec![kurbo::PathEl::MoveTo((0.0, 0.0).into()), kurbo::PathEl::LineTo((5_000.0, 0.0).into())]); + let one = [outline(&PathData::from_bezpath(&line))]; + assert!(turned_outlines(&one).is_none()); + assert_same_region(&stroke_region(&one), &stroke_region_upright(&one), "one segment"); + + // No overlaps: a row of separate squares. + let squares: Vec = (0..600).map(|i| square(i as f64 * 3.0, 0.0)).collect(); + let refs: Vec<(&PathData, FillRule)> = squares.iter().map(|p| (p, FillRule::NonZero)).collect(); + assert!(turned_fills(&refs).is_some()); + let apart = unite_all_with(&refs, DEFAULT_PRECISION); + assert_eq!(apart.subpaths.len(), 600); + assert_same_region(&apart, &unite_all_upright(&refs, DEFAULT_PRECISION), "separate squares"); + + // All anchors on one line: no area either way. + let flat = PathData::single(SubPath::new((0..2100).map(|i| Anchor::corner(Point::new(i as f64, 0.0))).collect(), true)); + let flat = [(&flat, FillRule::NonZero)]; + assert!(turned_fills(&flat).is_some()); + assert!(unite_all_with(&flat, DEFAULT_PRECISION).is_empty()); + assert!(unite_all_upright(&flat, DEFAULT_PRECISION).is_empty()); + + // Coincident segments: the same path twice unites to that path. + let mut rng = Rng(3); + let fill = zigzag(2, 1100, 0.0, &mut rng, true); + let twice = [(&fill, FillRule::NonZero), (&fill, FillRule::NonZero)]; + assert!(turned_fills(&twice).is_some()); + assert_same_region(&unite_all_with(&twice, DEFAULT_PRECISION), &unite_all_upright(&[(&fill, FillRule::NonZero)], DEFAULT_PRECISION), "twice"); + } +} diff --git a/crates/plugins/Cargo.toml b/crates/plugins/Cargo.toml index af9212c90..5e4117a18 100644 --- a/crates/plugins/Cargo.toml +++ b/crates/plugins/Cargo.toml @@ -12,6 +12,7 @@ workspace = true vectorcraft-geom = { workspace = true } vectorcraft-color = { workspace = true } vectorcraft-doc = { workspace = true } +log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } # The SHA-256 of a module, which the install prompt shows and a grant records. diff --git a/crates/plugins/src/host.rs b/crates/plugins/src/host.rs new file mode 100644 index 000000000..f47d8e69b --- /dev/null +++ b/crates/plugins/src/host.rs @@ -0,0 +1,507 @@ +//! What a plug-in may import from the host ([`IMPORTS`]), from plug-in API level 2 on: +//! `vc_progress` reports how far a run has got, `vc_cancelled` tells it the user or a host limit +//! asked it to stop, and `vc_log` writes a log line. A module may import them only when its +//! manifest gives `api` 2 and lists the `progress` permission, and the host checks each import when +//! the module is loaded ([`check_imports`], [`check_granted`]): one it doesn't offer, one with +//! another signature and one the manifest doesn't allow are refused by name. +//! +//! Each call's arguments are checked: progress is clamped and passed on only when it changes (with +//! a known total, to another tenth of a percent; past [`MAX_PROGRESS_REPORTS`], once per +//! [`PROGRESS_STRIDE`] changes), log text must lie inside the module's memory and be UTF-8, and +//! each line ([`MAX_LOG_LINE`]) and each run ([`MAX_LOG_LINES`], [`MAX_LOG_BYTES`], +//! [`MAX_LOG_READ`]) are capped. Nothing a module passes can panic the host. + +use wasmi::{Caller, Engine, Extern, ExternType, Linker, Module, StoreLimits, ValType}; + +use crate::manifest::Manifest; +use crate::permissions::{self, PROGRESS_API, Permission}; +use crate::{Error, Result}; + +/// The module a plug-in imports host functions from (the name a Rust or C toolchain gives +/// `extern` functions by default). +pub const MODULE: &str = "env"; + +/// What the host passes as the step's description with a plug-in's progress +/// ([`vectorcraft_doc::cancel::progress`]), so a progress window shows a share rather than a count +/// of artboards or files. +pub const PROGRESS_STEP: &str = "plug-in"; + +/// Longest log line: its first 1,024 bytes of text, shown as at most 1,024 characters with hidden +/// characters escaped. A longer one is cut and ends with `…`. +pub const MAX_LOG_LINE: usize = 1024; + +/// Most log lines one run writes; later ones are dropped, after one "log truncated" line. +pub const MAX_LOG_LINES: usize = 200; + +/// Most bytes of log text, as written with its escapes, one run writes; lines past it are dropped +/// as past [`MAX_LOG_LINES`]. +pub const MAX_LOG_BYTES: usize = 32 * 1024; + +/// Most bytes of text the host reads from a run's `vc_log` calls, checked or not: a call that +/// would read past it is dropped as past [`MAX_LOG_LINES`]. +pub const MAX_LOG_READ: usize = 1024 * 1024; + +/// Most progress reports one run passes on at the full rate; after them, one changed report in +/// [`PROGRESS_STRIDE`] is, and so is the first after a total becomes known or unknown. The run's +/// last report is always kept ([`Report::progress`]). +pub const MAX_PROGRESS_REPORTS: usize = 2000; + +/// Past [`MAX_PROGRESS_REPORTS`], how many changed reports go by for each one passed on. +pub const PROGRESS_STRIDE: usize = 1000; + +/// What `vc_log` returns: the line was written. +pub const LOG_WRITTEN: i32 = 0; +/// What `vc_log` returns: the run's log budget is spent, so the line was dropped. +pub const LOG_DROPPED: i32 = 1; +/// What `vc_log` returns: the text doesn't lie inside the module's memory. +pub const LOG_OUTSIDE_MEMORY: i32 = -1; +/// What `vc_log` returns: the text is not UTF-8. +pub const LOG_NOT_UTF8: i32 = -2; +/// What `vc_log` returns: the level is not 0 to 3. +pub const LOG_BAD_LEVEL: i32 = -3; + +/// One host import: its name and its type. +pub struct Import { + pub name: &'static str, + params: &'static [ValType], + results: &'static [ValType], + /// Its type as WebAssembly text writes it. + pub signature: &'static str, +} + +/// The host imports of API level 2, in module [`MODULE`]: +/// +/// - `vc_progress(done: i64, total: i64)`: `done` of `total` steps are finished (`total` 0 or less: +/// not known). Negative values count as 0 and `done` is clamped to `total`; +/// - `vc_cancelled() -> i32`: 1 once the user cancelled the run or its time budget ran out, else 0; +/// - `vc_log(level: i32, ptr: i32, len: i32) -> i32`: writes the UTF-8 text at `ptr` as a log line +/// of `level` (0 error, 1 warning, 2 information, 3 debug) → [`LOG_WRITTEN`], [`LOG_DROPPED`], +/// [`LOG_OUTSIDE_MEMORY`], [`LOG_NOT_UTF8`] or [`LOG_BAD_LEVEL`]. +pub const IMPORTS: [Import; 3] = [ + Import { name: "vc_progress", params: &[ValType::I64, ValType::I64], results: &[], signature: "(param i64 i64)" }, + Import { name: "vc_cancelled", params: &[], results: &[ValType::I32], signature: "(result i32)" }, + Import { + name: "vc_log", + params: &[ValType::I32, ValType::I32, ValType::I32], + results: &[ValType::I32], + signature: "(param i32 i32 i32) (result i32)", + }, +]; + +/// A log line's level. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LogLevel { + Error, + Warn, + Info, + Debug, +} + +impl LogLevel { + /// The level `vc_log`'s `level` names. + fn from_code(code: i32) -> Option { + match code { + 0 => Some(LogLevel::Error), + 1 => Some(LogLevel::Warn), + 2 => Some(LogLevel::Info), + 3 => Some(LogLevel::Debug), + _ => None, + } + } + + fn log_level(self) -> log::Level { + match self { + LogLevel::Error => log::Level::Error, + LogLevel::Warn => log::Level::Warn, + LogLevel::Info => log::Level::Info, + LogLevel::Debug => log::Level::Debug, + } + } +} + +/// A line a plug-in wrote with `vc_log`: its control and bidirectional formatting characters +/// escaped ([`permissions::display_text`]), and cut to [`MAX_LOG_LINE`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogLine { + pub level: LogLevel, + pub text: String, +} + +/// The text of the line that stands for the lines a run's log budget dropped. +pub const LOG_TRUNCATED: &str = "log truncated: later lines were dropped"; + +/// What a run reported through the host imports. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Report { + /// Its last progress report, clamped: `(done, total)`, `total` 0 when not known. + pub progress: Option<(u64, u64)>, + /// The lines it wrote, then [`LOG_TRUNCATED`] once its budget dropped one. + pub lines: Vec, + /// Did its log budget drop a line? + pub truncated: bool, +} + +/// When a run must stop (native: a wall-clock deadline). +#[derive(Clone, Copy)] +pub(crate) struct Deadline { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant, +} + +impl Deadline { + pub(crate) fn new(wall_time_ms: u64) -> Self { + #[cfg(target_arch = "wasm32")] + let _ = wall_time_ms; + Self { + #[cfg(not(target_arch = "wasm32"))] + at: std::time::Instant::now() + std::time::Duration::from_millis(wall_time_ms), + } + } + + pub(crate) fn passed(&self) -> bool { + #[cfg(not(target_arch = "wasm32"))] + if std::time::Instant::now() >= self.at { + return true; + } + false + } + + pub(crate) fn check(&self) -> Result<()> { + if self.passed() { Err(Error::Limit("time budget".into())) } else { Ok(()) } + } +} + +/// One run's state that the host imports read and write. +pub(crate) struct Run { + /// The plug-in's id, which its log lines carry. + id: String, + /// Are its progress reports passed on ([`vectorcraft_doc::cancel::progress`])? A live + /// effect's are not: it is drawn as part of something else, such as an export. + forward: bool, + /// Are its log lines written to the app's log? Not while the module is being loaded. + logged: bool, + deadline: Deadline, + /// The last report passed on, as per mille of a known total (with 1) or the count of an + /// unknown one (with 0), how many were, and how many changed ones went by since the last. + last: Option<(u64, u64)>, + reports: usize, + skipped: usize, + /// Bytes of log text written (with their escapes) and read. + log_bytes: usize, + log_read: usize, + pub(crate) report: Report, +} + +impl Run { + pub(crate) fn new(id: &str, forward: bool, logged: bool, deadline: Deadline) -> Self { + Run { + id: id.to_string(), + forward, + logged, + deadline, + last: None, + reports: 0, + skipped: 0, + log_bytes: 0, + log_read: 0, + report: Report::default(), + } + } + + fn progress(&mut self, done: i64, total: i64) { + let total = u64::try_from(total).unwrap_or(0); + let done = u64::try_from(done).unwrap_or(0); + let done = if total > 0 { done.min(total) } else { done }; + self.report.progress = Some((done, total)); + let key = if total > 0 { ((u128::from(done) * 1000 / u128::from(total)) as u64, 1) } else { (done, 0) }; + if !self.forward || self.last == Some(key) { + return; + } + let switched = self.last.is_some_and(|(_, t)| (t > 0) != (total > 0)); + if self.reports >= MAX_PROGRESS_REPORTS && !switched { + self.skipped += 1; + if self.skipped < PROGRESS_STRIDE { + return; + } + } + self.skipped = 0; + self.last = Some(key); + self.reports += 1; + vectorcraft_doc::cancel::progress(done, total, PROGRESS_STEP); + } + + fn cancelled(&self) -> i32 { + i32::from(vectorcraft_doc::cancel::is_cancelled() || self.deadline.passed()) + } + + /// Writes the `len` bytes at `ptr` in `memory` as a line of `level` → what `vc_log` returns. + fn log(&mut self, level: i32, memory: &[u8], ptr: usize, len: usize) -> i32 { + let Some(level) = LogLevel::from_code(level) else { return LOG_BAD_LEVEL }; + let Some(bytes) = ptr.checked_add(len).and_then(|end| memory.get(ptr..end)) else { return LOG_OUTSIDE_MEMORY }; + if self.report.truncated { + return LOG_DROPPED; + } + if self.log_read.saturating_add(len) > MAX_LOG_READ { + return self.truncate(); + } + self.log_read += len; + let Ok(text) = std::str::from_utf8(bytes) else { return LOG_NOT_UTF8 }; + // A character the cut splits is left out. + let head = text.get(..text.floor_char_boundary(MAX_LOG_LINE)).unwrap_or(text); + let shown = if head.len() < text.len() { + let mut shown = permissions::display_text(head, MAX_LOG_LINE - 1); + if !shown.ends_with('…') { + shown.push('…'); + } + shown + } else { + permissions::display_text(head, MAX_LOG_LINE) + }; + if self.report.lines.len() >= MAX_LOG_LINES || self.log_bytes + shown.len() > MAX_LOG_BYTES { + return self.truncate(); + } + self.log_bytes += shown.len(); + self.write(level, shown); + LOG_WRITTEN + } + + /// Drops the line and the run's later ones, after one line that says so → [`LOG_DROPPED`]. + fn truncate(&mut self) -> i32 { + self.report.truncated = true; + self.write(LogLevel::Warn, LOG_TRUNCATED.to_string()); + LOG_DROPPED + } + + fn write(&mut self, level: LogLevel, text: String) { + if self.logged { + log::log!(target: "vectorcraft_plugins", level.log_level(), "{}: {text}", self.id); + } + self.report.lines.push(LogLine { level, text }); + } +} + +/// What a store holds: its memory limits and the run's state. +pub(crate) struct Host { + pub(crate) limits: StoreLimits, + pub(crate) run: Run, +} + +/// A linker that offers the host imports when `granted`, and no import otherwise. +pub(crate) fn linker(engine: &Engine, granted: bool) -> Linker { + let mut linker = Linker::::new(engine); + if granted { + // Each name is defined once, so none of these fails. + let _ = linker.func_wrap(MODULE, "vc_progress", |mut caller: Caller<'_, Host>, done: i64, total: i64| { + caller.data_mut().run.progress(done, total); + }); + let _ = linker.func_wrap(MODULE, "vc_cancelled", |caller: Caller<'_, Host>| -> i32 { caller.data().run.cancelled() }); + let _ = linker.func_wrap(MODULE, "vc_log", |mut caller: Caller<'_, Host>, level: i32, ptr: i32, len: i32| -> i32 { + let Some(Extern::Memory(memory)) = caller.get_export("memory") else { return LOG_OUTSIDE_MEMORY }; + let (data, host) = memory.data_and_store_mut(&mut caller); + host.run.log(level, data, ptr as u32 as usize, len as u32 as usize) + }); + } + linker +} + +/// Checks that every import of `module` is a host import with the host's signature → their names. +/// Any other import is refused, and the error names it. +pub(crate) fn check_imports(module: &Module) -> Result> { + let mut names = Vec::new(); + for imp in module.imports() { + let offered = if imp.module() == MODULE { IMPORTS.iter().find(|i| i.name == imp.name()) } else { None }; + let (from, name) = (permissions::display_text(imp.module(), 64), permissions::display_text(imp.name(), 64)); + let Some(host) = offered else { + return Err(Error::Abi(format!( + "the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network" + ))); + }; + match imp.ty() { + ExternType::Func(f) if f.params() == host.params && f.results() == host.results => names.push(host.name), + _ => { + let signature = host.signature; + return Err(Error::Abi(format!("the module imports `env.{name}` with another type than the host's: it is {signature}"))); + } + } + } + Ok(names) +} + +/// Checks that `manifest` allows the host imports `imports`: `api` 2 and the `progress` +/// permission. The error names the first import it doesn't allow. +pub(crate) fn check_granted(manifest: &Manifest, imports: &[&str]) -> Result<()> { + match imports.first() { + Some(name) if manifest.api < PROGRESS_API || !manifest.permissions().contains(&Permission::Progress) => Err(Error::Abi(format!( + "the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress`" + ))), + _ => Ok(()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn run() -> Run { + Run::new("test", true, false, Deadline::new(60_000)) + } + + /// Progress is clamped: negative counts are 0 and `done` never passes a known total. + #[test] + fn progress_is_clamped() { + let mut r = run(); + for ((done, total), want) in + [((5, 3), (3, 3)), ((-4, 10), (0, 10)), ((7, -1), (7, 0)), ((i64::MAX, i64::MAX), (i64::MAX as u64, i64::MAX as u64))] + { + r.progress(done, total); + assert_eq!(r.report.progress, Some(want), "{done} of {total}"); + } + } + + /// Reports are passed on only when they change (with a known total, to another tenth of a + /// percent, however the total changes; with an unknown total, each change of `done`), and past + /// [`MAX_PROGRESS_REPORTS`] once per [`PROGRESS_STRIDE`] changes, except the first after the + /// total becomes known; the last one is always kept. + #[test] + fn progress_is_passed_on_at_a_bounded_rate() { + use std::cell::RefCell; + use std::rc::Rc; + let seen = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let token = vectorcraft_doc::cancel::CancelToken::new(); + let mut r = run(); + vectorcraft_doc::cancel::scope(&token, Some(sink), || { + for done in 0..=1_000_000 { + r.progress(done, 1_000_000); + } + }); + let seen = seen.borrow(); + assert_eq!(seen.len(), 1001, "one per tenth of a percent"); + assert_eq!(seen.last(), Some(&(1_000_000, 1_000_000, PROGRESS_STEP.to_string()))); + let mut unknown = run(); + let later = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let later = later.clone(); + Rc::new(move |d, t, _| later.borrow_mut().push((d, t))) + }; + vectorcraft_doc::cancel::scope(&token, Some(sink), || { + for done in 0..10_000 { + unknown.progress(done, 0); + } + assert_eq!(unknown.reports, MAX_PROGRESS_REPORTS + (10_000 - MAX_PROGRESS_REPORTS) / PROGRESS_STRIDE); + assert_eq!(unknown.report.progress, Some((9_999, 0)), "the last report is kept"); + // A total that becomes known is passed on at once, and the reports after it keep + // coming at the slower rate. + unknown.progress(10, 100); + assert_eq!(later.borrow().last(), Some(&(10, 100))); + for done in 11..=100 { + unknown.progress(done, 100); + } + }); + assert_eq!(later.borrow().last(), Some(&(10, 100)), "90 changes are fewer than the stride"); + assert_eq!(unknown.report.progress, Some((100, 100))); + // A new total at the same tenth of a percent is not a new step. + let mut retotalled = run(); + let steps = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let steps = steps.clone(); + Rc::new(move |d, t, _| steps.borrow_mut().push((d, t))) + }; + vectorcraft_doc::cancel::scope(&token, Some(sink), || { + for total in 10..2_010 { + retotalled.progress(total / 10, total - total % 10); + } + }); + assert_eq!(*steps.borrow(), [(1, 10)]); + assert_eq!(retotalled.report.progress, Some((200, 2_000))); + } + + /// Text outside memory, text that isn't UTF-8 and an unknown level are errors; a long line is + /// cut, also inside a character, and hidden characters are escaped. + #[test] + fn log_text_is_checked_and_cut() { + let mut r = run(); + let memory = b"hello\xff\xfe\nworld"; + assert_eq!(r.log(2, memory, 0, 5), LOG_WRITTEN); + assert_eq!(r.log(2, memory, 0, memory.len() + 1), LOG_OUTSIDE_MEMORY); + assert_eq!(r.log(2, memory, usize::MAX, 2), LOG_OUTSIDE_MEMORY); + assert_eq!(r.log(2, memory, 4, 3), LOG_NOT_UTF8); + assert_eq!(r.log(4, memory, 0, 5), LOG_BAD_LEVEL); + assert_eq!(r.log(-1, memory, 0, 5), LOG_BAD_LEVEL); + assert_eq!(r.log(0, memory, 7, 6), LOG_WRITTEN); + // The cut falls inside the 512th "é", which is left out. + let long = format!("a{}", "é".repeat(MAX_LOG_LINE)); + assert_eq!(r.log(1, long.as_bytes(), 0, long.len()), LOG_WRITTEN); + let lines = &r.report.lines; + assert_eq!(lines[0], LogLine { level: LogLevel::Info, text: "hello".into() }); + assert_eq!(lines[1], LogLine { level: LogLevel::Error, text: "\\u{000A}world".into() }); + assert_eq!(lines[2], LogLine { level: LogLevel::Warn, text: format!("a{}…", "é".repeat(MAX_LOG_LINE / 2 - 1)) }); + assert!(!r.report.truncated); + // Text past the cut is checked too. + let bad_tail = [vec![b'a'; MAX_LOG_LINE * 2], vec![0xff]].concat(); + assert_eq!(r.log(2, &bad_tail, 0, bad_tail.len()), LOG_NOT_UTF8); + // A cut line of escapes is at most MAX_LOG_LINE characters, its ellipsis included. + let controls = vec![1u8; MAX_LOG_LINE * 2]; + assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN); + let text = &r.report.lines[3].text; + assert!(text.starts_with("\\u{0001}") && text.ends_with('…') && text.chars().count() <= MAX_LOG_LINE, "{text}"); + } + + /// The byte budget counts a line as written, with its escapes, so short lines of hidden + /// characters can't write more than [`MAX_LOG_BYTES`]. + #[test] + fn log_budget_counts_escaped_text() { + let mut r = run(); + let controls = [1u8; 100]; + let shown = "\\u{0001}".len() * controls.len(); + let fit = MAX_LOG_BYTES / shown; + for i in 0..fit { + assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_WRITTEN, "line {i}"); + } + assert_eq!(r.log(2, &controls, 0, controls.len()), LOG_DROPPED); + let written: usize = r.report.lines.iter().filter(|l| l.text != LOG_TRUNCATED).map(|l| l.text.len()).sum(); + assert!(written <= MAX_LOG_BYTES, "{written}"); + } + + /// The host reads at most [`MAX_LOG_READ`] bytes of log text in a run, so long texts can't + /// make it check more; once past it, later calls are dropped without reading. + #[test] + fn log_text_read_is_bounded() { + let mut r = run(); + let big = vec![b'a'; MAX_LOG_READ / 4]; + for _ in 0..4 { + assert_eq!(r.log(2, &big, 0, big.len()), LOG_WRITTEN); + } + assert_eq!(r.log(2, b"x", 0, 1), LOG_DROPPED); + assert!(r.report.truncated); + let mut huge = run(); + let over = vec![0xffu8; MAX_LOG_READ + 1]; + assert_eq!(huge.log(2, &over, 0, over.len()), LOG_DROPPED, "dropped before it is checked"); + assert_eq!(huge.log(2, b"x", 0, 1), LOG_DROPPED); + } + + /// Lines past the run's budget are dropped, with one line that says so. + #[test] + fn log_lines_past_the_budget_are_dropped_with_one_truncation_line() { + let mut r = run(); + for i in 0..MAX_LOG_LINES + 50 { + let want = if i < MAX_LOG_LINES { LOG_WRITTEN } else { LOG_DROPPED }; + assert_eq!(r.log(2, b"line", 0, 4), want, "line {i}"); + } + assert_eq!(r.report.lines.len(), MAX_LOG_LINES + 1); + assert_eq!(r.report.lines.last(), Some(&LogLine { level: LogLevel::Warn, text: LOG_TRUNCATED.into() })); + assert!(r.report.truncated); + // So is text past the run's bytes. + let mut bytes = run(); + let big = vec![b'x'; MAX_LOG_LINE]; + let fit = MAX_LOG_BYTES / MAX_LOG_LINE; + for _ in 0..fit { + assert_eq!(bytes.log(2, &big, 0, big.len()), LOG_WRITTEN); + } + assert_eq!(bytes.log(2, b"x", 0, 1), LOG_DROPPED); + assert_eq!(bytes.report.lines.len(), fit + 1); + } +} diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 47037c4db..377c82b7c 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -3,8 +3,9 @@ //! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented //! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with: //! -//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that -//! imports anything is rejected; +//! - **no host imports but the three of API level 2** ([`host`]: progress, Cancel and log lines, +//! with the `progress` permission): no WASI, file system, network, clock or randomness. A module +//! that imports anything else is rejected; //! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is //! also enforced (native builds), so infinite loops end with an error. A filter may ask for a //! larger one ([`Budget::Heavy`]), which the host lets the user allow before installing it; @@ -29,6 +30,7 @@ pub mod dialog; pub mod effect; pub mod export; +pub mod host; pub mod import; pub mod manifest; pub mod objects; @@ -38,6 +40,7 @@ mod runtime; pub mod wat; pub use dialog::ParamUi; +pub use host::{LogLevel, LogLine, Report}; pub use manifest::{Budget, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; diff --git a/crates/plugins/src/manifest.rs b/crates/plugins/src/manifest.rs index de4013869..3a3b679ac 100644 --- a/crates/plugins/src/manifest.rs +++ b/crates/plugins/src/manifest.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value, json}; use crate::dialog::ParamUi; -use crate::permissions::{self, API_LEVEL, Permission}; +use crate::permissions::{self, API_LEVEL, PROGRESS_API, Permission}; use crate::{Error, Result}; /// Longest manifest the host reads. @@ -311,8 +311,11 @@ impl Manifest { if !(1..=API_LEVEL).contains(&self.api) { return Err(Error::Manifest(format!("`api` is {}; this host implements plug-in API levels 1 to {API_LEVEL}", self.api))); } - if let Some(names) = &self.permissions { - permissions::validate(self.kind, names)?; + if let Some(names) = &self.permissions + && permissions::validate(self.kind, names)?.contains(&Permission::Progress) + && self.api < PROGRESS_API + { + return Err(Error::Manifest(format!("permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API}"))); } self.validate_extensions()?; match (self.budget, self.kind) { @@ -681,12 +684,20 @@ mod tests { let exporter = Manifest::parse(br#"{"id":"a","name":"x","kind":"exporter","extensions":["plt"]}"#).unwrap(); assert_eq!(exporter.permissions(), [Permission::DocRead, Permission::ExportWrite]); assert!(!Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","permissions":[]}"#).unwrap().asks_permissions()); + // Level 2 adds `progress`, which only a level-2 manifest may list. + let level2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2,"permissions":["progress","doc.read"]}"#).unwrap(); + assert_eq!((level2.api, level2.permissions()), (2, vec![Permission::DocRead, Permission::Progress])); + assert_eq!(serde_json::to_value(&level2).unwrap()["api"], json!(2)); + let plain2 = Manifest::parse(br#"{"id":"a","name":"x","kind":"filter","api":2}"#).unwrap(); + assert!(!plain2.permissions().contains(&Permission::Progress), "never implied"); for (bad, says) in [ (r#"{"id":"a","name":"x","kind":"filter","permissions":["doc.read","net.fetch"]}"#, "unknown permission `net.fetch`"), (r#"{"id":"a","name":"x","kind":"filter","permissions":["ui.panel"]}"#, "reserved"), (r#"{"id":"a","name":"x","kind":"effect","permissions":["doc.read"]}"#, "effect.render"), (r#"{"id":"a","name":"x","kind":"filter","permissions":"doc.read"}"#, "manifest"), - (r#"{"id":"a","name":"x","kind":"filter","api":2}"#, "`api` is 2"), + (r#"{"id":"a","name":"x","kind":"filter","permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":1,"permissions":["progress"]}"#, "`progress` needs plug-in API level 2"), + (r#"{"id":"a","name":"x","kind":"filter","api":3}"#, "`api` is 3"), (r#"{"id":"a","name":"x","kind":"filter","api":0}"#, "`api` is 0"), (r#"{"id":"a","name":"x","kind":"filter","api":-1}"#, "manifest"), ] { diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 540a091a3..4b5daecf6 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -2,10 +2,11 @@ //! approves when it is installed ([`Grant`], kept in [`crate::registry`]), and which the host //! enforces on every run. //! -//! `doc.read`, `doc.write`, `state` and the reserved `progress` are the names of the plug-in -//! capability model in storytold/photocraft#662; the other names are VectorCraft's own. A manifest -//! without `permissions` gets the ones its kind implies ([`Permission::implied`]): what every -//! plug-in of that kind could do before manifests listed them. +//! `doc.read`, `doc.write`, `state` and `progress` are the names of the plug-in capability model in +//! storytold/photocraft#662; the other names are VectorCraft's own. A manifest without +//! `permissions` gets the ones its kind implies ([`Permission::implied`]): what every plug-in of +//! that kind could do before manifests listed them. `progress`, which lets a plug-in use the host +//! imports of API level 2 ([`crate::host`]), is never implied: a manifest lists it. use serde::{Deserialize, Serialize}; @@ -13,7 +14,11 @@ use crate::{Error, Kind, Result}; /// The plug-in API level this host implements. A manifest's `api` (1 when left out) may be at most /// this. -pub const API_LEVEL: u32 = 1; +pub const API_LEVEL: u32 = 2; + +/// The API level that adds the host imports ([`crate::host::IMPORTS`]) and the `progress` +/// permission they need. +pub const PROGRESS_API: u32 = 2; /// Most permissions a manifest may list. pub const MAX_PERMISSIONS: usize = 16; @@ -26,7 +31,7 @@ pub const MAX_SHOWN_FIELD: usize = 64; /// Names kept for host calls a later plug-in API adds. This host grants none of them, so a /// manifest that lists one is refused when it is installed. -pub const RESERVED: &[&str] = &["progress", "ui.panel", "ui.tool"]; +pub const RESERVED: &[&str] = &["ui.panel", "ui.tool"]; /// One thing a plug-in may do. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -50,11 +55,22 @@ pub enum Permission { /// Read a file the user opens and make a new document from it (an importer). #[serde(rename = "import.read")] ImportRead, + /// Report how far a run has got, see that the user asked it to stop and write log lines: the + /// host imports of API level 2 ([`crate::host::IMPORTS`]). + #[serde(rename = "progress")] + Progress, } /// Every permission this host grants, in the order prompts list them. -pub const ALL: [Permission; 6] = - [Permission::DocRead, Permission::DocWrite, Permission::State, Permission::EffectRender, Permission::ExportWrite, Permission::ImportRead]; +pub const ALL: [Permission; 7] = [ + Permission::DocRead, + Permission::DocWrite, + Permission::State, + Permission::EffectRender, + Permission::ExportWrite, + Permission::ImportRead, + Permission::Progress, +]; impl Permission { /// Its name in a manifest and in `plugin.install`'s `grant`. @@ -66,6 +82,7 @@ impl Permission { Permission::EffectRender => "effect.render", Permission::ExportWrite => "export.write", Permission::ImportRead => "import.read", + Permission::Progress => "progress", } } @@ -83,11 +100,18 @@ impl Permission { Permission::EffectRender => "Change how the objects its effect is applied to are drawn", Permission::ExportWrite => "Write the document as a file in its own format", Permission::ImportRead => "Read files you open in its format and make documents from them", + Permission::Progress => "Show its progress, see Cancel and write log lines", } } - /// The permissions a plug-in of `kind` may list, which are also the ones a manifest without - /// `permissions` gets. + /// May a plug-in of `kind` list it: one its kind implies, or `progress`, which any kind may + /// list. + pub fn applies(self, kind: Kind) -> bool { + self == Permission::Progress || Permission::implied(kind).contains(&self) + } + + /// The permissions a manifest of `kind` without `permissions` gets. A manifest may also list + /// `progress` ([`Permission::applies`]). pub fn implied(kind: Kind) -> &'static [Permission] { match kind { Kind::Filter => &[Permission::DocRead, Permission::DocWrite, Permission::State], @@ -124,7 +148,7 @@ pub(crate) fn validate(kind: Kind, names: &[String]) -> Result> } return Err(Error::Manifest(format!("unknown permission `{shown}`"))); }; - if !Permission::implied(kind).contains(&p) { + if !p.applies(kind) { return Err(Error::Manifest(format!("permission `{shown}` doesn't apply to a plug-in of kind `{}`", kind.name()))); } if out.contains(&p) { @@ -226,12 +250,21 @@ mod tests { assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render"])).is_ok()); assert!(validate(Kind::Exporter, &list(&["export.write", "doc.read"])).is_ok()); assert_eq!(validate(Kind::Importer, &list(&["import.read"])).unwrap(), [Permission::ImportRead]); + // Any kind may list `progress`, which no kind implies. + assert_eq!(validate(Kind::Filter, &list(&["progress", "doc.read"])).unwrap(), [Permission::DocRead, Permission::Progress]); + assert!(validate(Kind::Effect, &list(&["doc.read", "effect.render", "progress"])).is_ok()); + assert!(validate(Kind::Exporter, &list(&["progress", "export.write", "doc.read"])).is_ok()); + assert_eq!(validate(Kind::Importer, &list(&["progress", "import.read"])).unwrap(), [Permission::ImportRead, Permission::Progress]); + assert!( + [Kind::Filter, Kind::Effect, Kind::Exporter, Kind::Importer].iter().all(|k| !Permission::implied(*k).contains(&Permission::Progress)) + ); for (kind, names, says) in [ (Kind::Importer, &["import.read", "doc.read"][..], "`doc.read` doesn't apply to a plug-in of kind `importer`"), (Kind::Importer, &[], "needs the permission `import.read`"), (Kind::Exporter, &["export.write", "doc.read", "import.read"], "`import.read` doesn't apply to a plug-in of kind `exporter`"), (Kind::Filter, &["doc.read", "clipboard"][..], "unknown permission `clipboard`"), - (Kind::Filter, &["progress"], "`progress` is reserved"), + (Kind::Filter, &["ui.panel"], "`ui.panel` is reserved"), + (Kind::Filter, &["progress", "progress"], "`progress` is listed twice"), (Kind::Filter, &["export.write"], "doesn't apply to a plug-in of kind `filter`"), (Kind::Exporter, &["doc.read", "export.write", "doc.read"], "`doc.read` is listed twice"), (Kind::Effect, &["doc.read"], "needs the permission `effect.render`"), diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index f5666fb5b..e6c289b8f 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -1,12 +1,14 @@ //! The wasmi host: loading, instantiating and calling a plug-in under resource limits. use wasmi::{ - Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, - TypedResumableCall, WasmParams, WasmResults, + Config, CustomFuelCosts, EnforcedLimits, Engine, Instance, Memory, Module, Store, StoreLimitsBuilder, TypedFunc, TypedResumableCall, WasmParams, + WasmResults, }; +use crate::host::{self, Deadline, Host, Report, Run}; use crate::manifest::{MAX_MANIFEST_BYTES, Manifest}; -use crate::{Error, Result}; +use crate::permissions::Permission; +use crate::{Error, Kind, Result}; /// The plug-in ABI version this host implements (`vc_abi_version` must return it). pub const ABI_VERSION: i32 = 1; @@ -96,33 +98,10 @@ impl std::fmt::Debug for Plugin { } } -/// When a run must stop (native: a wall-clock deadline). -struct Deadline { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant, -} - -impl Deadline { - fn new(limits: &Limits) -> Self { - #[cfg(target_arch = "wasm32")] - let _ = limits; - Self { - #[cfg(not(target_arch = "wasm32"))] - at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms), - } - } - fn check(&self) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] - if std::time::Instant::now() >= self.at { - return Err(Error::Limit("time budget".into())); - } - Ok(()) - } -} - -/// One live instance: a store with its own memory limit, and the module's exports. +/// One live instance: a store with its own memory limit and the run's state, and the module's +/// exports. struct Live { - store: Store, + store: Store, instance: Instance, memory: Memory, } @@ -152,7 +131,9 @@ fn wasm_err(e: wasmi::Error) -> Error { type RunArgs = (i32, i32, i32, i32); impl Plugin { - /// Validates and loads a module: checks the ABI version, the exports and reads the manifest. + /// Validates and loads a module: checks the ABI version, the exports and reads the manifest, and + /// checks that each host import it asks for is one the host offers ([`crate::host::IMPORTS`]) + /// and its manifest allows. pub fn load(bytes: &[u8], limits: Limits) -> Result { if bytes.len() > limits.max_module_bytes { return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes))); @@ -168,17 +149,12 @@ impl Plugin { .set_max_recursion_depth(limits.max_recursion_depth.max(16)); let engine = Engine::new(&config); let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?; - if let Some(imp) = module.imports().next() { - return Err(Error::Abi(format!( - "the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)", - imp.module(), - imp.name() - ))); - } + let imports = host::check_imports(&module)?; let hash = crate::permissions::sha256_hex(bytes); let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), hash, source: None, serial: 0 }; - let deadline = Deadline::new(&p.limits); - let mut live = p.instantiate()?; + let deadline = Deadline::new(p.limits.wall_time_ms); + // The manifest isn't read yet: every host import is linked, and what it reports is dropped. + let mut live = p.instantiate(Run::new("", false, false, deadline), true, &mut Report::default())?; let version: TypedFunc<(), i32> = live.func("vc_abi_version")?; let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?; if v != ABI_VERSION { @@ -191,6 +167,7 @@ impl Plugin { e => e, })?; p.manifest = Manifest::parse(&text)?; + host::check_granted(&p.manifest, &imports)?; // A heavy filter's runs get the larger budget the host allows for one, which the host // lets the user allow or refuse before installing it. if p.manifest.budget == crate::Budget::Heavy { @@ -237,16 +214,24 @@ impl Plugin { self.serial = serial; } - fn instantiate(&self) -> Result { + /// A new instance for `run`, with the host imports linked when `granted`. When the module's + /// start function fails, what it reported through the host imports goes to `report`. + fn instantiate(&self, run: Run, granted: bool, report: &mut Report) -> Result { let limits = StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build(); - let mut store = Store::new(&self.engine, limits); - store.limiter(|l| l); + let mut store = Store::new(&self.engine, Host { limits, run }); + store.limiter(|h| &mut h.limits); // The start function (if any) runs on one slice of fuel: it runs before `call` can check // the deadline or a cancel between slices, so a heavy budget would let it run for minutes. store.set_fuel(self.limits.fuel_base.min(FUEL_SLICE)).map_err(wasm_err)?; - let linker = Linker::::new(&self.engine); - let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?; + let linker = host::linker(&self.engine, granted); + let instance = match linker.instantiate_and_start(&mut store, &self.module) { + Ok(instance) => instance, + Err(e) => { + *report = std::mem::take(&mut store.data_mut().run.report); + return Err(wasm_err(e)); + } + }; let memory = instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?; Ok(Live { store, instance, memory }) @@ -254,6 +239,12 @@ impl Plugin { /// Calls `f` with at most `fuel` instructions, in slices, checking the deadline and the /// caller's cancellation ([`vectorcraft_doc::cancel`]) between them. + /// + /// wasmi pauses before an instruction that the store's fuel can't pay for, and charges nothing + /// for it. It charges a `memory.fill` or `memory.copy` for its whole length before writing, so + /// up to that cost can be left in the store at a pause. Each slice tops the store up to the + /// slice size, keeping that fuel, and a run ends with the instruction budget only when the + /// store's fuel and the rest of the budget together can't pay for the next instruction. fn call(&self, live: &mut Live, f: &TypedFunc, params: P, fuel: u64, deadline: &Deadline) -> Result { let mut left = fuel; let give = left.min(FUEL_SLICE); @@ -266,14 +257,16 @@ impl Plugin { TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())), TypedResumableCall::OutOfFuel(inv) => { let need = inv.required_fuel(); - if left == 0 || need > left { + let held = live.store.get_fuel().map_err(wasm_err)?; + let total = held.saturating_add(left); + if need > total { return Err(Error::Limit("instruction budget".into())); } deadline.check()?; vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; - let give = left.min(FUEL_SLICE.max(need)); - left -= give; - live.store.set_fuel(give).map_err(wasm_err)?; + let fill = total.min(FUEL_SLICE.max(need)).max(held); + left -= fill - held; + live.store.set_fuel(fill).map_err(wasm_err)?; call = inv.resume(&mut live.store).map_err(wasm_err)?; } } @@ -288,21 +281,39 @@ impl Plugin { /// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one). pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { - // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must - // still not take the document down (native builds; wasm aborts on panic anyway). - let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits))); - run.map_err(|_| Error::Trap("the runtime panicked".into()))? + self.run_reporting(input, params, limits).0 } - fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result> { + /// [`Plugin::run_limited`], and what the run reported through the host imports: its last + /// progress report and its log lines, which also go to the app's log under its id. Inside a + /// [`vectorcraft_doc::cancel::scope`] its progress goes to the scope's sink (a live effect's + /// doesn't) and `vc_cancelled` sees the scope's Cancel. + pub fn run_reporting(&self, input: &[u8], params: &[u8], limits: &Limits) -> (Result>, Report) { + let mut report = Report::default(); + // wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must + // still not take the document down (native builds; wasm aborts on panic anyway). + let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits, &mut report))); + (run.map_err(|_| Error::Trap("the runtime panicked".into())).and_then(|r| r), report) + } + + fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits, report: &mut Report) -> Result> { let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2); total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?; - let deadline = Deadline::new(limits); - let mut live = self.instantiate()?; + let deadline = Deadline::new(limits.wall_time_ms); + let m = &self.manifest; + let run = Run::new(&m.id, m.kind != Kind::Effect, true, deadline); + let mut live = self.instantiate(run, m.permissions().contains(&Permission::Progress), report)?; + let out = self.run_live(&mut live, input, params, limits, &deadline); + *report = std::mem::take(&mut live.store.data_mut().run.report); + out + } + + /// Hands `input` and `params` to the instance `live` and runs `vc_run` → the output JSON bytes. + fn run_live(&self, live: &mut Live, input: &[u8], params: &[u8], limits: &Limits, deadline: &Deadline) -> Result> { let alloc: TypedFunc = live.func("vc_alloc")?; let run: TypedFunc = live.func("vc_run")?; let place = |live: &mut Live, bytes: &[u8]| -> Result { - let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, &deadline)? as u32 as usize; + let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, deadline)? as u32 as usize; if ptr == 0 { return Err(Error::Failed("vc_alloc returned 0 (out of memory)".into())); } @@ -311,10 +322,14 @@ impl Plugin { live.memory.data_mut(&mut live.store).get_mut(ptr..end).ok_or_else(outside)?.copy_from_slice(bytes); Ok(ptr) }; - let iptr = place(&mut live, input)?; - let pptr = place(&mut live, params)?; + let iptr = place(live, input)?; + let pptr = place(live, params)?; let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte)); - let packed = self.call(&mut live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, &deadline)?; + let packed = self.call(live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, deadline)?; + // A cancelled run, or one past its time budget, changes nothing, also when the plug-in saw + // `vc_cancelled` and returned. + vectorcraft_doc::cancel::check().map_err(|_| Error::Cancelled)?; + deadline.check()?; if packed < 0 { return Err(Error::Failed(format!("vc_run returned error code {packed}"))); } diff --git a/crates/plugins/src/wat.rs b/crates/plugins/src/wat.rs index 13570d36f..a6583501e 100644 --- a/crates/plugins/src/wat.rs +++ b/crates/plugins/src/wat.rs @@ -42,11 +42,51 @@ pub fn returning(manifest: &str, output: &str) -> String { /// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then /// returns `output`. fn then_returning(manifest: &str, first: &str, output: &str) -> String { + with_imports(manifest, "", first, output) +} + +/// A plug-in with the module fields `imports` before the others, whose `vc_run` runs `first`, then +/// returns `output`. +pub fn with_imports(manifest: &str, imports: &str, first: &str, output: &str) -> String { let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len()); - let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output)); + let extra = format!(r#"{imports} (data (i32.const {OUTPUT_AT}) "{}")"#, string(output)); module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1) } +/// The host imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_progress`, +/// `$vc_cancelled` and `$vc_log`. +pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (param i64 i64))) + (import "env" "vc_cancelled" (func $vc_cancelled (result i32))) + (import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#; + +/// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the +/// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then +/// returns `output`. +pub fn reporting(manifest: &str, first: &str, output: &str) -> String { + with_imports(manifest, HOST_IMPORTS, first, output) +} + +/// A plug-in of API level 2 that counts `steps` down, reporting each step's progress and checking +/// `vc_cancelled` before it: when that says the run was cancelled it returns `stopped`, else +/// `output` once the steps are done. +pub fn cooperating(manifest: &str, steps: u64, stopped: &str, output: &str) -> String { + let first = format!( + r#"(local $n i64) (local.set $n (i64.const {steps})) + (block $done (loop $step + (br_if $done (i64.le_s (local.get $n) (i64.const 0))) + (call $vc_progress (i64.sub (i64.const {steps}) (local.get $n)) (i64.const {steps})) + (if (call $vc_cancelled) (then (return (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const {STOPPED_AT}))))) + (local.set $n (i64.sub (local.get $n) (i64.const 1))) + (br $step)))"#, + len = stopped.len(), + ); + let imports = format!(r#"{HOST_IMPORTS} (data (i32.const {STOPPED_AT}) "{}")"#, string(stopped)); + with_imports(manifest, &imports, &first, output) +} + +/// Where [`cooperating`] keeps what it returns when it is cancelled. +const STOPPED_AT: usize = 0x8000; + /// A plug-in that counts `steps` down before it returns `output`. Each step costs a few /// instructions, so a test picks work that a light budget can't finish and a heavy one can, or /// that runs until it is cancelled. diff --git a/crates/plugins/tests/abi.rs b/crates/plugins/tests/abi.rs index 5fbb694d5..f5b78d224 100644 --- a/crates/plugins/tests/abi.rs +++ b/crates/plugins/tests/abi.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; -use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, heavy_terms, registry, wat as tpl}; +use vectorcraft_plugins::{Error, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Kind, Limits, Plugin, Report, heavy_terms, registry, wat as tpl}; /// The example Desaturate plug-in, built from `crates/plugins/example`. const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm"); @@ -155,6 +155,45 @@ fn budgets_stop_runaway_plugins() { assert_eq!(e, Error::Limit("instruction budget".into())); } +/// A filter that runs `op`, an 8 MiB bulk memory instruction costing about 131,000 instructions, +/// 160 times (about 21 million instructions in all), or forever when `endless`. +fn bulk_module(op: &str, endless: bool) -> Vec { + let bulk = match op { + "fill" => "(memory.fill (i32.const 1024) (i32.const 7) (i32.const 8388608))", + "copy" => "(memory.copy (i32.const 1024) (i32.const 2048) (i32.const 8387584))", + _ => panic!("no bulk operation `{op}`"), + }; + let run = if endless { + format!("(loop $work {bulk} (br $work)) {}", tpl::ECHO) + } else { + format!( + "(local $n i32) (local.set $n (i32.const 160)) \ + (loop $work {bulk} (local.set $n (i32.sub (local.get $n) (i32.const 1))) (br_if $work (local.get $n))) {}", + tpl::ECHO + ) + }; + build(&tpl::module(MANIFEST, &run, "", 130)) +} + +fn bulk_run(op: &str, endless: bool, fuel_base: u64) -> vectorcraft_plugins::Result> { + let p = Plugin::load(&bulk_module(op, endless), Limits { fuel_base, fuel_per_byte: 0, ..Limits::default() })?; + p.run(INPUT.as_bytes(), b"{}") +} + +/// A bulk memory instruction that the fuel left in a slice of the budget can't pay for pauses the +/// run without using that fuel, and the run keeps it: 160 fills or copies that cross the first +/// slice finish within 21 million instructions, though the pause left about 75,000 of them in the +/// slice. With fewer instructions than the work takes, or with a loop that never ends, the run +/// still ends with the instruction budget. +#[test] +fn a_bulk_memory_instruction_at_the_end_of_a_slice_keeps_the_run_s_fuel() { + for op in ["fill", "copy"] { + assert_eq!(bulk_run(op, false, 21_000_000), Ok(INPUT.as_bytes().to_vec()), "{op} within its budget"); + assert_eq!(bulk_run(op, false, 15_000_000), Err(Error::Limit("instruction budget".into())), "{op} over its budget"); + assert_eq!(bulk_run(op, true, 21_000_000), Err(Error::Limit("instruction budget".into())), "endless {op}"); + } +} + #[test] fn wall_clock_budget_stops_runaway_plugins() { let spin = ok_module("(loop $l (br $l)) (i64.const 0)"); @@ -308,6 +347,161 @@ fn folder_loading_skips_bad_files() { std::fs::remove_dir_all(&dir).ok(); } +/// A filter of plug-in API level 2 that may use the host imports. +const LEVEL2: &str = + r#"{"id":"test.level2","name":"Level 2","version":"1","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}"#; + +/// The error of loading `bytes`, as text. +fn load_err(bytes: &[u8]) -> String { + Plugin::load(bytes, Limits::default()).expect_err("the module must be refused").to_string() +} + +/// A module may import only the host's three functions, with the host's types, and only when its +/// manifest gives `api` 2 and lists `progress`; the error names the import it refuses. +#[test] +fn host_imports_are_checked_when_a_module_is_loaded() { + let with = |manifest: &str, import: &str| build(&tpl::with_imports(manifest, import, "", INPUT)); + let unknown = with(LEVEL2, r#"(import "env" "vc_clock" (func (result i64)))"#); + assert!(load_err(&unknown).contains("imports `env.vc_clock`, which the host doesn't offer"), "{}", load_err(&unknown)); + let elsewhere = with(LEVEL2, r#"(import "vc" "vc_log" (func (param i32 i32 i32) (result i32)))"#); + assert!(load_err(&elsewhere).contains("imports `vc.vc_log`, which the host doesn't offer"), "{}", load_err(&elsewhere)); + let wrong_type = with(LEVEL2, r#"(import "env" "vc_progress" (func (param i32 i32)))"#); + assert!(load_err(&wrong_type).contains("imports `env.vc_progress` with another type than the host's: it is (param i64 i64)")); + let memory = with(LEVEL2, r#"(import "env" "vc_cancelled" (global i32))"#); + assert!(load_err(&memory).contains("imports `env.vc_cancelled` with another type"), "{}", load_err(&memory)); + let progress = r#"(import "env" "vc_progress" (func (param i64 i64)))"#; + let ungranted = r#"{"id":"test.ungranted","name":"U","kind":"filter","api":2,"permissions":["doc.read","doc.write"]}"#; + let says = "imports `env.vc_progress`, which a plug-in may use only at plug-in API level 2 (`api` 2) and with the permission `progress`"; + for manifest in [ungranted, r#"{"id":"test.implied","name":"U","kind":"filter","api":2}"#, MANIFEST] { + let e = load_err(&with(manifest, progress)); + assert!(e.contains(says), "{manifest}: {e}"); + } + let p = Plugin::load(&with(LEVEL2, progress), Limits::default()).unwrap(); + assert_eq!(p.run(INPUT.as_bytes(), b"{}").unwrap(), INPUT.as_bytes()); + // A level-2 plug-in that imports nothing runs as a level-1 one does. + let plain = Plugin::load(&build(&tpl::returning(LEVEL2, INPUT)), Limits::default()).unwrap(); + assert_eq!(plain.run_reporting(INPUT.as_bytes(), b"{}", plain.limits()), (Ok(INPUT.as_bytes().to_vec()), Report::default())); +} + +/// Progress is clamped and passed on to the caller's sink as a share; the run's last report is kept. +#[test] +fn progress_reports_are_clamped_and_reach_the_caller() { + use std::{cell::RefCell, rc::Rc}; + let first = "(call $vc_progress (i64.const 50) (i64.const 10)) (call $vc_progress (i64.const -3) (i64.const 8)) (call $vc_progress (i64.const 3) (i64.const 8))"; + let p = Plugin::load(&build(&tpl::reporting(LEVEL2, first, INPUT)), Limits::default()).unwrap(); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let token = vectorcraft_doc::cancel::CancelToken::new(); + let (out, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits())); + assert_eq!(out.unwrap(), INPUT.as_bytes()); + assert_eq!(report.progress, Some((3, 8))); + let step = vectorcraft_plugins::host::PROGRESS_STEP.to_string(); + assert_eq!(*seen.borrow(), [(10, 10, step.clone()), (0, 8, step.clone()), (3, 8, step)]); + // A live effect's progress stays its own: it is drawn as part of something else. + let effect = r#"{"id":"test.level2.effect","name":"E","kind":"effect","api":2,"permissions":["doc.read","effect.render","progress"]}"#; + let e = Plugin::load(&build(&tpl::reporting(effect, first, INPUT)), Limits::default()).unwrap(); + let quiet = Rc::new(RefCell::new(0)); + let sink: vectorcraft_doc::cancel::ProgressSink = { + let quiet = quiet.clone(); + Rc::new(move |_, _, _| *quiet.borrow_mut() += 1) + }; + let (_, report) = vectorcraft_doc::cancel::scope(&token, Some(sink), || e.run_reporting(INPUT.as_bytes(), b"{}", e.limits())); + assert_eq!((report.progress, *quiet.borrow()), (Some((3, 8)), 0)); +} + +/// Cancel makes `vc_cancelled` return 1, and a plug-in that checks it stops at once; what it returns +/// then is discarded, so the run is `Cancelled`; past the time budget the same holds. One that +/// ignores it still ends at its instruction budget. Outside a cancelled scope it returns 0. +#[test] +fn cancel_reaches_a_cooperating_plugin_and_a_budget_ends_one_that_ignores_it() { + let p = + Plugin::load(&build(&tpl::cooperating(LEVEL2, 1 << 40, r#"{"stopped":1}"#, INPUT)), Limits { fuel_base: u64::MAX / 4, ..Limits::default() }) + .unwrap(); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let (out, report) = vectorcraft_doc::cancel::scope(&token, None, || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits())); + assert_eq!(out, Err(Error::Cancelled), "what it returned after the Cancel is discarded"); + assert_eq!(report.progress, Some((0, 1 << 40)), "it saw the Cancel on its first step"); + // Not cancelled: it runs all its steps. + let short = Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits::default()).unwrap(); + let (out, report) = short.run_reporting(INPUT.as_bytes(), b"{}", short.limits()); + assert_eq!((out.unwrap(), report.progress), (INPUT.as_bytes().to_vec(), Some((999, 1000)))); + // Past its time budget `vc_cancelled` returns 1 too, and what it returns then is discarded. + let late = + Plugin::load(&build(&tpl::cooperating(LEVEL2, 1000, r#"{"stopped":1}"#, INPUT)), Limits { wall_time_ms: 0, ..Limits::default() }).unwrap(); + let (out, report) = late.run_reporting(INPUT.as_bytes(), b"{}", late.limits()); + assert_eq!((out, report.progress), (Err(Error::Limit("time budget".into())), Some((0, 1000)))); + // One that never checks ends at its budget, cancelled or not. + let ignoring = build(&tpl::reporting(LEVEL2, "(loop $l (br $l))", INPUT)); + let p = Plugin::load(&ignoring, Limits { fuel_base: 1_000_000, ..Limits::default() }).unwrap(); + let r = vectorcraft_doc::cancel::scope(&token, None, || p.run(INPUT.as_bytes(), b"{}")); + assert_eq!(r, Err(Error::Limit("instruction budget".into()))); +} + +/// A log line longer than the cap is cut; lines past the run's budget are dropped, with one line +/// that says so; text outside memory, text that isn't UTF-8 and an unknown level are errors the +/// plug-in sees, never a panic. +#[test] +fn log_lines_are_capped_and_bad_text_is_an_error_the_plugin_sees() { + use vectorcraft_plugins::host::{self, LOG_BAD_LEVEL, LOG_NOT_UTF8, LOG_OUTSIDE_MEMORY, MAX_LOG_LINE, MAX_LOG_LINES}; + // `$text` at 0x2000 holds "hi", then 0xff, then 2000 bytes of "x". + let expect = |level: i32, ptr: &str, len: &str, want: i32| { + format!("(if (i32.ne (call $vc_log (i32.const {level}) {ptr} {len}) (i32.const {want})) (then unreachable))") + }; + let first = [ + expect(2, "(i32.const 0x2000)", "(i32.const 2)", 0), + expect(1, "(i32.const 0x2003)", "(i32.const 2000)", 0), + expect(2, "(i32.const -1)", "(i32.const 5)", LOG_OUTSIDE_MEMORY), + expect(2, "(i32.const 0x2000)", "(i32.const -1)", LOG_OUTSIDE_MEMORY), + expect(2, "(i32.mul (memory.size) (i32.const 65536))", "(i32.const 1)", LOG_OUTSIDE_MEMORY), + expect(2, "(i32.const 0x2000)", "(i32.const 3)", LOG_NOT_UTF8), + expect(9, "(i32.const 0x2000)", "(i32.const 2)", LOG_BAD_LEVEL), + ] + .concat(); + let data = format!(r#"(data (i32.const 0x2000) "hi\ff{}")"#, "x".repeat(2000)); + let imports = format!("{} {data}", tpl::HOST_IMPORTS); + let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &first, INPUT)), Limits::default()).unwrap(); + let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()); + assert_eq!(out.unwrap(), INPUT.as_bytes(), "every call returned what it should"); + assert_eq!(report.lines.len(), 2); + assert_eq!(report.lines[0], vectorcraft_plugins::LogLine { level: vectorcraft_plugins::LogLevel::Info, text: "hi".into() }); + assert_eq!(report.lines[1].text, format!("{}…", "x".repeat(MAX_LOG_LINE - 2)), "cut at the cap, ellipsis included"); + // Past the budget: dropped, with one line that says so. + let flood = format!( + "(local $i i32) (loop $l (drop (call $vc_log (i32.const 2) (i32.const 0x2000) (i32.const 2))) (local.set $i (i32.add (local.get $i) (i32.const 1))) (br_if $l (i32.lt_u (local.get $i) (i32.const {}))))", + MAX_LOG_LINES * 3 + ); + let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, &flood, INPUT)), Limits::default()).unwrap(); + let (out, report) = p.run_reporting(INPUT.as_bytes(), b"{}", p.limits()); + assert!(out.is_ok() && report.truncated); + assert_eq!(report.lines.len(), MAX_LOG_LINES + 1); + assert_eq!(report.lines.iter().filter(|l| l.text == host::LOG_TRUNCATED).count(), 1); +} + +/// What a module's start function reports through the host imports before it fails is kept in the +/// run's report. This start function logs, reports progress and traps only when the run was +/// cancelled, so the module loads. +#[test] +fn what_a_start_function_reports_before_it_fails_is_kept() { + let start = r#"(data (i32.const 0x2000) "hi") + (func $start (if (call $vc_cancelled) (then + (drop (call $vc_log (i32.const 2) (i32.const 0x2000) (i32.const 2))) + (call $vc_progress (i64.const 1) (i64.const 4)) + unreachable))) + (start $start)"#; + let imports = format!("{} {start}", tpl::HOST_IMPORTS); + let p = Plugin::load(&build(&tpl::with_imports(LEVEL2, &imports, "", INPUT)), Limits::default()).unwrap(); + let token = vectorcraft_doc::cancel::CancelToken::new(); + token.cancel(); + let (out, report) = vectorcraft_doc::cancel::scope(&token, None, || p.run_reporting(INPUT.as_bytes(), b"{}", p.limits())); + assert!(out.is_err(), "{out:?}"); + assert_eq!(report.lines, [vectorcraft_plugins::LogLine { level: vectorcraft_plugins::LogLevel::Info, text: "hi".into() }]); + assert_eq!(report.progress, Some((1, 4))); +} + /// A heavy filter installs once the user agrees, which loading a folder doesn't ask: it is skipped /// and reported. #[test] diff --git a/crates/render/src/bounds.rs b/crates/render/src/bounds.rs index dec8db1c5..5cb622e5d 100644 --- a/crates/render/src/bounds.rs +++ b/crates/render/src/bounds.rs @@ -34,3 +34,15 @@ pub fn drawn_bounds(doc: &Document, n: &Node) -> Option { pub fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> { fx::drawn_outline(doc, n) } + +/// The outlines a drag draws for `nodes` with Real-time Drawing off: one for each object in them, +/// without uniting a group's members. `None` when the outlines' points and the objects evaluated to +/// make them come to more than `max_points`. +pub fn drawn_outlines<'a>(doc: &Document, nodes: impl IntoIterator, max_points: usize) -> Option> { + let symbols = doc.symbol_boxes(); + let (mut left, mut out) = (max_points, vec![]); + for n in nodes { + fx::drawn_outlines(doc, &symbols, n, &mut left, &mut out)?; + } + Some(out) +} diff --git a/crates/render/src/changed.rs b/crates/render/src/changed.rs index 8516d5b0b..01fd6da01 100644 --- a/crates/render/src/changed.rs +++ b/crates/render/src/changed.rs @@ -131,8 +131,9 @@ impl Diff<'_> { } /// A group or layer that only its members changed in, without a clipping mask or effects of - /// its own that draw from all of them, changed where they did. Any other object that changed - /// counts whole, before and after. + /// its own that draw from all of them, changed where they did. A path or compound path that + /// only had anchors moved changed around them ([`moved_anchors`]). Any other object that + /// changed counts whole, before and after. fn node(&mut self, old: &Arc, new: &Arc) { if Arc::ptr_eq(old, new) { return; @@ -141,6 +142,15 @@ impl Diff<'_> { && same_but_members(old, new) { self.children(a, b); + } else if let Some((area, moved, anchors)) = moved_anchors(old, new) { + if moved <= FEW_MOVED && anchors >= MANY_ANCHORS { + self.add(area); + } else { + // The object's box before and after holds every pixel that changed too, and so + // does where the two overlap. + let whole = vectorcraft_geom::union_opt(crate::brush_fx::cull_bounds(&self.old, old), crate::brush_fx::cull_bounds(&self.new, new)); + self.add(area.zip(whole).map(|(a, w)| a.intersect(w))); + } } else { self.add_old(Some(old)); self.add_new(Some(new)); @@ -180,3 +190,145 @@ fn same_but_members(a: &Node, b: &Node) -> bool { }; strip(a) == strip(b) } + +/// The largest size of a coordinate, in points, that [`moved_anchors`] measures: past it, a path +/// drawn at a high zoom may stop being drawn at all (`crate::node_checks::drawable`). +const MOVED_LIMIT: f64 = 1e6; + +/// The most anchors of an object that may move for [`Diff::node`] to take the area round them +/// without also measuring the whole object before and after, which costs as much as its anchors. +const FEW_MOVED: usize = 64; + +/// The fewest anchors of an object that [`Diff::node`] leaves unmeasured whole when at most +/// [`FEW_MOVED`] of them moved. +const MANY_ANCHORS: usize = 1024; + +/// What path or compound path `old` changed in `new` when only anchors of its paths moved (in +/// place, with as many anchors in each subpath, each closed or open as before): the box of the +/// anchors and handles of every segment that starts or ends at a moved anchor, and of the line +/// that closes the fill of an open subpath at a moved end, before and after, grown by the farthest +/// its strokes reach (`None` when no anchor moved), how many anchors moved and how many it has. +/// Outside that box the path's segments, their fill and their strokes' joints stay as they were. +/// None when anything else changed, when it paints with something its whole shape decides +/// (effects, an opacity mask, a paint other than a solid color, dashes, a width profile, +/// arrowheads or a brush), or when a coordinate measured is not finite or larger in size than +/// [`MOVED_LIMIT`]. +fn moved_anchors(old: &Node, new: &Node) -> Option<(Option, usize, usize)> { + if !same_but_anchors(old, new) || !local_paint(old) { + return None; + } + let (area, moved, anchors) = match (&old.kind, &new.kind) { + (NodeKind::Path { path: a, .. }, NodeKind::Path { path: b, .. }) => moved_in(a, b)?, + (NodeKind::Compound { children: a, .. }, NodeKind::Compound { children: b, .. }) => { + let (mut area, mut moved, mut anchors) = (None, 0, 0); + for (x, y) in a.iter().zip(b) { + if Arc::ptr_eq(x, y) { + anchors += y.path_data().map_or(0, |p| p.anchor_count()); + continue; + } + match (&x.kind, &y.kind) { + (NodeKind::Path { path: p, .. }, NodeKind::Path { path: q, .. }) if same_but_anchors(x, y) => { + let (a, m, n) = moved_in(p, q)?; + (area, moved, anchors) = (vectorcraft_geom::union_opt(area, a), moved + m, anchors + n); + } + _ => return None, + } + } + (area, moved, anchors) + } + _ => return None, + }; + let reach = old.appearance.outset(); + Some((area.map(|r| r.inflate(reach, reach)), moved, anchors)) +} + +/// Are `a` and `b` the same but for where the anchors of their paths are: two paths, or two +/// compound paths with the same members by id, without extras, live shapes, guides or clipping? +fn same_but_anchors(a: &Node, b: &Node) -> bool { + let Node { id, name, visible, locked, opacity, blend, isolate, knockout, knockout_shape, appearance, kind, bbox_angle, extras } = a; + let kinds = match (kind, &b.kind) { + ( + NodeKind::Path { path: p, rule: r, live: None, clipping: false, guide: false }, + NodeKind::Path { path: q, rule: s, live: None, clipping: false, guide: false }, + ) => { + r == s + && p.subpaths.len() == q.subpaths.len() + && p.subpaths.iter().zip(&q.subpaths).all(|(x, y)| x.anchors.len() == y.anchors.len() && x.closed == y.closed) + } + (NodeKind::Compound { children: c, rule: r }, NodeKind::Compound { children: d, rule: s }) => { + r == s && c.len() == d.len() && c.iter().zip(d).all(|(x, y)| x.id == y.id) + } + _ => false, + }; + let none = vectorcraft_doc::Extras::default(); + kinds + && *id == b.id + && *name == b.name + && *visible == b.visible + && *locked == b.locked + && *opacity == b.opacity + && *blend == b.blend + && *isolate == b.isolate + && *knockout == b.knockout + && *knockout_shape == b.knockout_shape + && *appearance == b.appearance + && *bbox_angle == b.bbox_angle + && *extras == none + && b.extras == none +} + +/// Does `n` paint each pixel from the segments near it alone: no effects, every visible fill and +/// stroke a solid color, and no stroke with dashes, a width profile, pressure, tilt, bearing or +/// rotation, arrowheads, a brush or effects? +fn local_paint(n: &Node) -> bool { + use vectorcraft_doc::AppearanceItem; + let solid = |p: &vectorcraft_color::Paint| matches!(p, vectorcraft_color::Paint::Solid { .. } | vectorcraft_color::Paint::None); + n.appearance.effects.is_empty() + && !crate::fx::has_fx(n) + && !crate::fx::has_object_fx(n) + && n.appearance.items.iter().all(|i| match i { + AppearanceItem::Fill(f) => solid(&f.paint) && f.effects.is_empty(), + AppearanceItem::Stroke(s) => { + solid(&s.paint) + && s.effects.is_empty() + && s.dash.is_none() + && s.profile.is_none() + && s.pressure.is_none() + && s.tilt.is_none() + && s.bearing.is_none() + && s.rotation.is_none() + && s.start_arrow.is_none() + && s.end_arrow.is_none() + && s.brush.is_none() + } + }) +} + +/// The box of the anchors and handles of the segments that start or end at an anchor that moved +/// from `a` to `b` (paths with the same subpaths, by anchor count and closing), and the ends of +/// the line that closes the fill of an open subpath at a moved end, in both (`None` when none +/// moved), how many moved and how many anchors `b` has. None when one of those coordinates is not +/// finite or larger in size than [`MOVED_LIMIT`]. +fn moved_in(a: &vectorcraft_geom::PathData, b: &vectorcraft_geom::PathData) -> Option<(Option, usize, usize)> { + let (mut area, mut moved, mut anchors): (Option, usize, usize) = (None, 0, 0); + for (x, y) in a.subpaths.iter().zip(&b.subpaths) { + let n = x.anchors.len(); + anchors += n; + for i in (0..n).filter(|&i| x.anchors[i] != y.anchors[i]) { + moved += 1; + // The anchors before and after it, round the end of an open subpath too: its fill + // closes it with a line from the last anchor to the first. + let (before, after) = ((i + n - 1) % n, (i + 1) % n); + for sp in [x, y] { + let (at, from, to) = (&sp.anchors[i], &sp.anchors[before], &sp.anchors[after]); + for p in [at.p, at.h_in, at.h_out, from.p, from.h_out, to.p, to.h_in] { + if !(p.x.abs() <= MOVED_LIMIT && p.y.abs() <= MOVED_LIMIT) { + return None; + } + area = Some(area.map_or(Rect::from_points(p, p), |r| r.union_pt(p))); + } + } + } + } + Some((area, moved, anchors)) +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 305ece398..abdf1666c 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -70,7 +70,7 @@ mod renderer; #[cfg(test)] pub(crate) use bounds::visual_bounds; -pub(crate) use bounds::{cull_bounds, drawn_outline, painted_reach, path_cull_bounds}; +pub(crate) use bounds::{cull_bounds, drawn_outline, drawn_outlines, painted_reach, path_cull_bounds}; #[cfg(test)] pub(crate) use entries::Drawn; pub(crate) use entries::{DocInputs, FxGeom, Reads, ShadowEntry}; diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 8bccf2c36..3e2692179 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -1,7 +1,8 @@ -use super::predicates::{has_fx, has_object_fx}; +use super::predicates::{has_fx, has_object_fx, is_container}; use super::raster::doc_raster_ppi; use super::{ - Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, Rect, Renderer, Shape, SymbolBoxes, effects, + Affine, AppearanceItem, Arc, BezPath, Document, Effect, FillRule, GeomContext, Node, NodeKind, PathEl, Rect, Renderer, Shape, SymbolBoxes, + effects, }; fn node_bezpath(n: &Node) -> Option { @@ -232,6 +233,78 @@ pub(crate) fn drawn_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRu } } +/// Adds the outlines of `n` to `out`, one per object and without uniting them: the visible members of +/// a group, layer or intertwine (a Shaper group's without its source art) and the evaluated art of a +/// blend, envelope, repeat, Pathfinder result or object with object effects. A gradient mesh adds its +/// mesh lines and a symbol instance its bounds, as the selection highlight shows them; any other +/// object, a clip group included, adds [`drawn_outline`]. Each outline's points and each evaluated +/// object are taken from `left`; `None` when they do not fit in it. +pub(crate) fn drawn_outlines(doc: &Document, symbols: &SymbolBoxes, n: &Node, left: &mut usize, out: &mut Vec) -> Option<()> { + if let Some(art) = effects::crop_marks_art(n) { + return match art.children().and_then(|c| c.first()) { + Some(object) => drawn_outlines(doc, symbols, object, left, out), + None => Some(()), + }; + } + if has_object_fx(n) { + let makes_art = effects::has_revolve(n) || effects::has_solid(n) || effects::addons::halftone::has_halftone(n); + // Effects that leave a mesh's shape alone evaluate it to its many colored pieces. + if let NodeKind::Mesh(m) = &n.kind + && !makes_art + && !effects::has_geometry(&n.appearance.effects) + { + return push_outline(Some(m.lines().to_bezpath()), left, out); + } + let mut object = n.clone(); + // A group's own fills and strokes repeat its members' geometry as more art. + if is_container(n) && !makes_art && !effects::has_item_geometry(n) { + object.appearance.items.clear(); + } + let art = Renderer::new().fx_art(doc, &Arc::new(object), false); + if art.children().is_some() && !has_object_fx(&art) { + return evaluated_outlines(doc, symbols, &art, left, out); + } + return push_outline(effects::clip_outline(&art).map(|(path, _)| path), left, out); + } + if effects::has_pathfinder(n) + && let Some(art) = effects::evaluate_container(Some(doc), n).filter(|art| !effects::has_pathfinder(art)) + { + return evaluated_outlines(doc, symbols, &art, left, out); + } + match &n.kind { + NodeKind::Group { children, clip: false } | NodeKind::Layer { children, clip: false, .. } | NodeKind::Intertwine { children } => { + for c in children.iter().skip(usize::from(n.extras().shaper.is_some())).filter(|c| c.visible) { + drawn_outlines(doc, symbols, c, left, out)?; + } + Some(()) + } + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) => { + evaluated_outlines(doc, symbols, &effects::expanded_live_group(Some(doc), n), left, out) + } + NodeKind::Mesh(m) => push_outline(Some(m.lines().to_bezpath()), left, out), + NodeKind::SymbolInstance { .. } => { + push_outline(n.geometric_bounds_with(symbols).map(|b| vectorcraft_geom::shapes::rectangle(b).to_bezpath()), left, out) + } + _ => push_outline(drawn_outline(doc, n).map(|(path, _)| path), left, out), + } +} + +/// [`drawn_outlines`] of `art`, the evaluated form of a live object or an object with effects, with +/// each of its objects taken from `left`. +fn evaluated_outlines(doc: &Document, symbols: &SymbolBoxes, art: &Node, left: &mut usize, out: &mut Vec) -> Option<()> { + *left = left.checked_sub(art.children().map_or(0, Vec::len))?; + drawn_outlines(doc, symbols, art, left, out) +} + +/// Adds `path` to `out` with its points taken from `left`; `None` when they do not fit in it. +fn push_outline(path: Option, left: &mut usize, out: &mut Vec) -> Option<()> { + if let Some(path) = path { + *left = left.checked_sub(path.elements().iter().filter(|el| !matches!(el, PathEl::ClosePath)).count())?; + out.push(path); + } + Some(()) +} + /// How far raster effects `fx` paint beyond `b`, each spreading what the ones before it paint /// ([`spread`]). fn outset(symbols: &SymbolBoxes, fx: &[Effect], b: Rect) -> f64 { diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 1240e413b..6b7aa49f7 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -10,6 +10,26 @@ use super::{ effects, fill_rule, peniko, }; +#[cfg(test)] +thread_local! { + /// How many silhouettes of drop shadows and outer glows renderers on this thread have drawn: + /// one per shadow or glow raster made ([`Renderer::shadow_raster`]) and per one drawn through a + /// filter layer ([`Renderer::draw_shadow_filter`]). + static SILHOUETTES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`SILHOUETTES`] of this thread. +#[cfg(test)] +pub(super) fn silhouettes() -> u64 { + SILHOUETTES.get() +} + +/// Adds one to [`SILHOUETTES`]. +#[cfg(test)] +fn note_silhouette() { + SILHOUETTES.set(SILHOUETTES.get().saturating_add(1)); +} + impl Renderer { /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) as a cached raster: the /// silhouette `paint` draws is rendered once into a small crop, blurred and tinted on the CPU, @@ -125,6 +145,8 @@ impl Renderer { let decoded = crate::paint::decode_failures(); let outer_image_left_out = self.image_left_out.replace(false); let pattern_images_left_out = crate::pattern::images_left_out(); + #[cfg(test)] + note_silhouette(); self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); let failed = crate::paint::decode_failures() - decoded; let image_left_out = self.image_left_out.unwrap_or(false) || crate::pattern::images_left_out() > pattern_images_left_out; @@ -230,6 +252,8 @@ impl Renderer { // The offset moves the content rather than the filter: vello_cpu drops layer content // that lies entirely outside the viewport before filtering. let reach = reach + Vec2::new(dx, dy); + #[cfg(test)] + note_silhouette(); self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { c.set_transform(fr.view); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); diff --git a/crates/render/src/fx/tests.rs b/crates/render/src/fx/tests.rs index 74ff632d6..d964a5244 100644 --- a/crates/render/src/fx/tests.rs +++ b/crates/render/src/fx/tests.rs @@ -467,9 +467,10 @@ fn a_glow_too_large_for_a_full_size_raster_keeps_its_solid_silhouette() { } #[test] -fn thirty_stacked_glows_draw_quickly_cached_or_not() { +fn thirty_stacked_glows_draw_thirty_silhouettes_cached_or_not() { // GUARDS: each glow is of the content with the glows before it; drawing those again for every glow costs 2^n. // A shape draws through the shadow cache, a symbol instance without it (the filter layers). + // Each glow's silhouette is drawn once. let glows: Vec<(&str, serde_json::Value)> = (0..30).map(|k| ("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 20, "blur": 1 + k % 4}))).collect(); let shape = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, glows.clone()); @@ -486,9 +487,10 @@ fn thirty_stacked_glows_draw_quickly_cached_or_not() { let l = instanced.layers[0].id; instanced.insert(Some(l), 0, instance).unwrap(); for d in [&shape, &instanced] { - let start = std::time::Instant::now(); + let before = super::renderer::silhouettes(); let p = render(d).pixel(25, 50); - assert!(start.elapsed() < std::time::Duration::from_secs(20), "{:?}", start.elapsed()); + let drawn = super::renderer::silhouettes() - before; + assert_eq!(drawn, 30, "silhouettes drawn for 30 glows"); assert!(p[1] < 250, "the glows show: {p:?}"); } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 46a0ca64a..68020dcfa 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -224,7 +224,7 @@ mod renderer; mod text_geom; mod threads; -pub use bounds::{drawn_bounds, drawn_outline, effect_bounds, painted_bounds}; +pub use bounds::{drawn_bounds, drawn_outline, drawn_outlines, effect_bounds, painted_bounds}; pub use frame::FrameStats; use frame::{ClipEntry, ClipPaintEntry, Frame, FxArtEntry, GeomEntry, PlacedEntry, PlacedKey, PtrHasher, PtrMap, SliceEntry, StrokeEntry, StrokeKey}; #[cfg(test)] diff --git a/crates/render/src/renderer.rs b/crates/render/src/renderer.rs index 2ad5170d4..490703aa2 100644 --- a/crates/render/src/renderer.rs +++ b/crates/render/src/renderer.rs @@ -31,6 +31,8 @@ pub(crate) use paint::{CROP_STROKES, crop_line}; impl Renderer { pub fn new() -> Self { + // Envelopes map type from its glyphs here too, without an engine session. + vectorcraft_text::register_envelope_ink(); Self { texts: FrameCache::new(lru::TEXT_BUDGET, 1024), text_ids: PtrMap::default(), diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 82a1374da..f7799d02a 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1333,7 +1333,7 @@ fn invalid_dash_pattern_strokes_solid() { let d = line(Some(pattern.clone())); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || tx.send(render(&d)).ok()); - let img = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish")); + let img = rx.recv_timeout(std::time::Duration::from_secs(300)).unwrap_or_else(|_| panic!("rendering dash {pattern:?} did not finish")); assert_eq!(img.pixels, solid.pixels, "dash {pattern:?}"); } } diff --git a/crates/render/src/tests/changed.rs b/crates/render/src/tests/changed.rs index e5f1f913d..459397149 100644 --- a/crates/render/src/tests/changed.rs +++ b/crates/render/src/tests/changed.rs @@ -3,7 +3,7 @@ use crate::*; use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::Appearance; -use vectorcraft_geom::{Point, shapes}; +use vectorcraft_geom::{Anchor, PathData, Point, SubPath, Vec2, shapes}; const W: u32 = 160; const H: u32 = 120; @@ -258,3 +258,166 @@ fn moving_stretched_type_changes_where_its_stretched_strokes_reach() { new.node_mut(text).unwrap().transform(Affine::translate((9.0, 7.0)), false); area(redraw(&old, &new)); } + +/// `n` anchors round an ellipse at (55, 40) with radii `rx` and `ry` and a ripple, every third a +/// corner and the others smooth with handles along the ellipse. +fn wavy(n: usize, rx: f64, ry: f64) -> SubPath { + let anchors = (0..n) + .map(|i| { + let t = i as f64 / n as f64 * std::f64::consts::TAU; + let k = 1.0 + 0.06 * (t * 12.0).sin(); + let p = Point::new(55.0 + rx * k * t.cos(), 40.0 + ry * k * t.sin()); + if i % 3 == 0 { + return Anchor::corner(p); + } + let along = Vec2::new(-rx * t.sin(), ry * t.cos()) * (0.4 / n as f64); + Anchor { h_in: p - along, h_out: p + along, ..Anchor::corner(p) } + }) + .collect(); + SubPath::new(anchors, true) +} + +/// The seven-pointed star {7/3} round (55, 40), each edge split by 39 anchors on it: its edges +/// cross, so parts of it are covered more than once. +fn star() -> SubPath { + let tip = |i: usize| { + let t = (i * 3 % 7) as f64 / 7.0 * std::f64::consts::TAU; + Point::new(55.0 + 45.0 * t.sin(), 40.0 - 35.0 * t.cos()) + }; + let anchors = (0..7).flat_map(|i| (0..40).map(move |j| Anchor::corner(tip(i).lerp(tip(i + 1), j as f64 / 40.0)))).collect(); + SubPath::new(anchors, true) +} + +/// A document with path `sub` filled with `rule`, stroked `stroke` points wide when that is more +/// than 0, and its id. +fn long_path(sub: SubPath, rule: FillRule, stroke: f64) -> (Document, NodeId) { + let mut d = Document::new(110.0, 80.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let line = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None }; + let mut n = Node::path(id, PathData::single(sub), Appearance::basic(Paint::solid(Color::rgb(0.1, 0.5, 0.8)), line, stroke)); + if let NodeKind::Path { rule: r, .. } = &mut n.kind { + *r = rule; + } + let id = d.insert(Some(l), 0, n).unwrap(); + (d, id) +} + +/// `d` with anchor `i` of the first subpath of path `id` and its handles moved by `by`. +fn move_anchor(d: &Document, id: NodeId, i: usize, by: Vec2) -> Document { + let mut d = d.clone(); + if let Some(NodeKind::Path { path, .. }) = d.node_mut(id).map(|n| &mut n.kind) { + let a = &mut path.subpaths[0].anchors[i]; + (a.p, a.h_in, a.h_out) = (a.p + by, a.h_in + by, a.h_out + by); + } + d +} + +/// Moving one anchor of a long filled path changes the fill only round the segments at that +/// anchor, with either fill rule, a smooth or a corner anchor, and a closing segment. +#[test] +fn moving_an_anchor_of_a_long_path_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (old, id) = long_path(wavy(2000, 45.0, 32.0), rule, 0.0); + for i in [0, 700, 1001] { + let a = area(redraw(&old, &move_anchor(&old, id, i, Vec2::new(4.0, -3.0)))); + assert!(a.width() < 8.0 && a.height() < 8.0, "{rule:?}, anchor {i}: {a:?}"); + } + } +} + +/// The fill of an open path closes it with a line from its last anchor to its first: moving an +/// end changes where that line was and is too. +#[test] +fn moving_an_end_of_an_open_filled_path_changes_the_line_that_closes_its_fill() { + let mut half = wavy(3000, 45.0, 32.0); + half.anchors.truncate(1500); + half.closed = false; + let (old, id) = long_path(half, FillRule::NonZero, 0.0); + for i in [0, 1499] { + redraw(&old, &move_anchor(&old, id, i, Vec2::new(0.0, 12.0))); + } +} + +/// Where a path covers its middle twice, the two fill rules draw it differently: an anchor moved +/// across that part changes only round its segments. +#[test] +fn moving_an_anchor_where_a_path_crosses_itself_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let (old, id) = long_path(star(), rule, 0.0); + let a = area(redraw(&old, &move_anchor(&old, id, 20, Vec2::new(-6.0, 5.0)))); + assert!(a.width() < 12.0 && a.height() < 12.0, "{rule:?}: {a:?}"); + } +} + +/// A stroke with miter joints changes round the moved anchor's segments and as far as its spikes +/// reach from them, and the open end of a stroke with square caps as far as its cap's corners. +#[test] +fn moving_an_anchor_of_a_stroked_path_changes_as_far_as_its_joints_reach() { + let (old, id) = long_path(star(), FillRule::NonZero, 1.5); + let a = area(redraw(&old, &move_anchor(&old, id, 60, Vec2::new(3.0, 4.0)))); + assert!(a.width() < 30.0, "{a:?}"); + let mut open = star(); + open.closed = false; + let (old, id) = long_path(open, FillRule::NonZero, 3.0); + let mut sq = old.clone(); + if let Some(AppearanceItem::Stroke(s)) = sq.node_mut(id).unwrap().appearance.items.get_mut(1) { + s.cap = vectorcraft_doc::LineCap::Square; + } + let a = area(redraw(&sq, &move_anchor(&sq, id, 279, Vec2::new(5.0, 2.0)))); + assert!(a.width() < 50.0, "{a:?}"); +} + +/// A compound path with a hole changes only round the moved anchor of the member it moved in. +#[test] +fn moving_an_anchor_of_a_compound_path_changes_only_round_its_segments() { + for rule in [FillRule::NonZero, FillRule::EvenOdd] { + let mut old = Document::new(110.0, 80.0); + let l = old.layers[0].id; + let (outer, inner) = (old.alloc_id(), old.alloc_id()); + let mut hole = wavy(600, 25.0, 15.0); + if rule == FillRule::NonZero { + // Wound the other way, so it is a hole by either rule. + hole.anchors.reverse(); + for a in &mut hole.anchors { + std::mem::swap(&mut a.h_in, &mut a.h_out); + } + } + let members = vec![ + Arc::new(Node::path(outer, PathData::single(wavy(1500, 45.0, 32.0)), Appearance::default())), + Arc::new(Node::path(inner, PathData::single(hole), Appearance::default())), + ]; + let mut compound = Node::new(old.alloc_id(), NodeKind::Compound { children: members, rule }); + compound.appearance = Appearance::basic(Paint::solid(Color::rgb(0.8, 0.3, 0.1)), Paint::None, 0.0).into(); + old.insert(Some(l), 0, compound).unwrap(); + let new = move_anchor(&old, inner, 150, Vec2::new(3.0, 3.0)); + let a = area(redraw(&old, &new)); + assert!(a.width() < 8.0 && a.height() < 8.0, "{rule:?}: {a:?}"); + } +} + +/// A gradient fill and a dashed stroke are laid out by the whole path: moving one anchor changes +/// all of it, as does adding an anchor. +#[test] +fn a_path_painted_by_its_whole_shape_changes_whole_when_an_anchor_moves() { + let (plain, id) = long_path(wavy(400, 45.0, 32.0), FillRule::NonZero, 1.0); + let mut graded = plain.clone(); + let stops = vec![vectorcraft_color::GradientStop::new(0.0, Color::BLACK), vectorcraft_color::GradientStop::new(1.0, Color::WHITE)]; + graded.node_mut(id).unwrap().appearance.fill_mut().unwrap().paint = Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new( + vectorcraft_color::Gradient::new(vectorcraft_color::GradientKind::Linear, stops), + ))); + let mut dashed = plain.clone(); + if let Some(AppearanceItem::Stroke(s)) = dashed.node_mut(id).unwrap().appearance.items.get_mut(1) { + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![3.0, 2.0], ..Default::default() }); + } + for old in [graded, dashed] { + let a = area(redraw(&old, &move_anchor(&old, id, 100, Vec2::new(2.0, 2.0)))); + assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}"); + } + let mut added = plain.clone(); + if let Some(NodeKind::Path { path, .. }) = added.node_mut(id).map(|n| &mut n.kind) { + path.subpaths[0].anchors.insert(10, Anchor::corner(Point::new(50.0, 40.0))); + } + let a = area(redraw(&plain, &added)); + assert!(a.width() > 80.0 && a.height() > 60.0, "{a:?}"); +} diff --git a/crates/render/src/tests/fontchange.rs b/crates/render/src/tests/fontchange.rs index c98af03d3..2ca5b5978 100644 --- a/crates/render/src/tests/fontchange.rs +++ b/crates/render/src/tests/fontchange.rs @@ -320,3 +320,35 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() { assert_eq!(after, Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels, "{what}: as a new renderer draws it"); } } + +/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds, +/// and measures the type again when its font becomes available. +#[test] +fn a_renderer_maps_enveloped_type_from_its_glyphs_in_the_current_font() { + let _adding = adding_fonts(); + const FAMILY: &str = "Mapped Serif 4"; + let style = CharStyle { size: 50.0, font_family: FAMILY.into(), ..Default::default() }; + let t = TextObject::point(Point::new(10.0, 60.0), "Hamburg", style); + let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true }; + let envelope = Node::new( + vectorcraft_doc::NodeId(1), + NodeKind::Envelope { + content: vec![Arc::new(Node::new(vectorcraft_doc::NodeId(2), NodeKind::Text(Box::new(t.clone()))))], + kind: Box::new(kind), + fidelity: 50.0, + editing: false, + options: Default::default(), + frame: Box::new(Affine::IDENTITY), + }, + ); + let ink = || t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + let src = || vectorcraft_doc::live::EnvelopeMap::of(&envelope).unwrap().src(); + let off = |a: Rect, b: Rect| [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())); + let _ = Renderer::new(); + let before = ink(); + assert!(off(src(), before) < 1e-6, "{:?} vs {before:?}", src()); + assert!(vectorcraft_text::FontDb::global().add_font(serif_as(FAMILY)) > 0); + let after = ink(); + assert!(off(before, after) > 0.5, "the type draws in the font that arrived: {before:?} vs {after:?}"); + assert!(off(src(), after) < 1e-6, "{:?} vs {after:?}", src()); +} diff --git a/crates/render/tests/envelope_ink.rs b/crates/render/tests/envelope_ink.rs new file mode 100644 index 000000000..7622143f5 --- /dev/null +++ b/crates/render/tests/envelope_ink.rs @@ -0,0 +1,30 @@ +//! A renderer made without an engine session makes envelopes measure type by its glyph outlines. +//! One test in its own process, so nothing else has registered the measure before the renderer is +//! made. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::Arc; + +use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect}; + +/// The largest distance between the edges of `a` and `b`. +fn off(a: Rect, b: Rect) -> f64 { + [a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().fold(0.0f64, |m, v| m.max(v.abs())) +} + +#[test] +fn a_new_renderer_measures_type_in_envelopes_by_its_glyphs() { + let t = TextObject::point(Point::new(10.0, 60.0), "Type", CharStyle { size: 48.0, ..Default::default() }); + let n = Arc::new(Node::new(NodeId(1), NodeKind::Text(Box::new(t.clone())))); + let laid = n.geometric_bounds().unwrap(); + let ink = t.xf.transform_rect_bbox(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).ink_bounds().unwrap()); + assert!(laid.height() - ink.height() > 1.0, "the laid-out box {laid:?} reaches past the glyphs {ink:?}"); + let before = vectorcraft_doc::live::envelope_content_bounds(std::slice::from_ref(&n)).unwrap(); + assert!(off(before, laid) < 1e-6, "with nothing registered, type keeps its laid-out box: {before:?} vs {laid:?}"); + + let _ = vectorcraft_render::Renderer::new(); + let after = vectorcraft_doc::live::envelope_content_bounds(&[n]).unwrap(); + assert!(off(after, ink) < 1e-6, "after a renderer is made, type is measured by its glyphs: {after:?} vs {ink:?}"); +} diff --git a/crates/svg/src/import/css.rs b/crates/svg/src/import/css.rs index 30432771a..3593a870b 100644 --- a/crates/svg/src/import/css.rs +++ b/crates/svg/src/import/css.rs @@ -120,6 +120,18 @@ fn is_ident(c: char) -> bool { /// How deep `:not()`s may nest (real style sheets nest one or two). const MAX_NOT_NESTING: usize = 8; +#[cfg(test)] +thread_local! { + /// How many times [`Selector::matches`] has tested a selector against an element on this thread. + static SELECTOR_TESTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// [`SELECTOR_TESTS`] of this thread. +#[cfg(test)] +fn selector_tests() -> u64 { + SELECTOR_TESTS.get() +} + impl Selector { /// `None` for selectors this cascade doesn't support (they never match). fn parse(s: &str) -> Option { @@ -273,6 +285,8 @@ impl Selector { memo.insert(key, hit); hit } + #[cfg(test)] + SELECTOR_TESTS.set(SELECTOR_TESTS.get().saturating_add(1)); go(&self.parts, n, &mut HashMap::new()) } } diff --git a/crates/svg/src/import/css/tests.rs b/crates/svg/src/import/css/tests.rs index 89b519858..d4704192f 100644 --- a/crates/svg/src/import/css/tests.rs +++ b/crates/svg/src/import/css/tests.rs @@ -249,11 +249,11 @@ proptest! { } } -/// 50,000 class rules give 50,000 elements, each with one of the classes, their fill within -/// 20 s. +/// 5,000 class rules give 5,000 elements, each with one of the classes, their fill, testing +/// about one rule per element. #[test] -fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { - const N: usize = 50_000; +fn styling_5000_elements_with_5000_rules_tests_about_one_rule_each() { + const N: usize = 5_000; let rules: String = (0..N).map(|i| format!(".c{i} {{ fill: #{i:06x} }}\n")).collect(); let rects: String = (0..N).map(|i| format!(r#""#)).collect(); let svg = format!(r#"{rects}"#); @@ -262,9 +262,12 @@ fn styling_50000_elements_with_50000_rules_finishes_within_20_s() { let doc = roxmltree::Document::parse(&svg).unwrap(); let st = Styles::new(&doc); let fills: Vec> = doc.descendants().filter(|n| n.tag_name().name() == "rect").map(|n| st.own(n, "fill")).collect(); - tx.send(fills).ok(); + tx.send((fills, selector_tests())).ok(); }); - let fills = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 20 s")); + let (fills, tests) = + rx.recv_timeout(std::time::Duration::from_secs(300)).unwrap_or_else(|_| panic!("styling {N} elements did not finish within 300 s")); + // Testing every rule against every element would test N × N selectors. + assert!(tests <= 2 * N as u64, "styling {N} elements tested {tests} selectors"); assert_eq!(fills.len(), N); for (i, fill) in fills.iter().enumerate() { assert_eq!(fill.as_deref(), Some(format!("#{i:06x}").as_str())); diff --git a/crates/svg/src/import/files.rs b/crates/svg/src/import/files.rs index b6d488c9e..9de3bebcd 100644 --- a/crates/svg/src/import/files.rs +++ b/crates/svg/src/import/files.rs @@ -11,6 +11,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use usvg::{ImageHrefResolver, ImageKind, roxmltree}; use vectorcraft_doc::LinkInfo; +use vectorcraft_doc::links::missing_placeholder; use super::{Edits, depth, href}; use crate::{ImportOptions, MAX_ELEMENT_DEPTH, MAX_SVGZ, SvgError}; @@ -62,22 +63,6 @@ pub(in crate::import) struct Linked { pub missing: bool, } -/// The side of the placeholder image, in pixels. -const MARKER: u32 = 64; - -/// The placeholder of a missing linked image: a light tile, crossed out (drawn here). -fn marker() -> Option> { - let last = MARKER - 1; - let img = image::RgbaImage::from_fn(MARKER, MARKER, |x, y| { - let edge = x == 0 || y == 0 || x == last || y == last; - let cross = x.abs_diff(y) <= 1 || (x + y).abs_diff(last) <= 1; - image::Rgba(if edge || cross { [200, 60, 60, 255] } else { [236, 236, 236, 255] }) - }); - let mut png = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).ok()?; - Some(png) -} - /// `s` with `%XX` escapes decoded (an href is a URL). fn percent_decoded(s: &str) -> String { if !s.contains('%') { @@ -159,7 +144,7 @@ impl Files { }; let source = source.or_else(|why| { warnings.push(format!("linked image '{h}' {why}: a placeholder keeps its place until it is relinked")); - let bytes = Arc::new(placeholder.get_or_insert_with(marker).clone().ok_or(why)?); + let bytes = Arc::new(placeholder.get_or_insert_with(missing_placeholder).clone().ok_or(why)?); files.links.insert(key(&bytes), Linked { link: LinkInfo::new(path), missing: true }); Ok::<_, &str>(Source::Raster(ImageKind::PNG, bytes)) }); @@ -479,10 +464,4 @@ mod tests { assert!(!read("2147483647")); assert!(read("1")); } - - #[test] - fn the_placeholder_is_a_png() { - let png = marker().unwrap(); - assert_eq!(image::load_from_memory(&png).unwrap().width(), MARKER); - } } diff --git a/crates/svg/src/import/importer.rs b/crates/svg/src/import/importer.rs index 49bcfaab7..55cf0aa91 100644 --- a/crates/svg/src/import/importer.rs +++ b/crates/svg/src/import/importer.rs @@ -26,6 +26,13 @@ pub(super) struct SymbolMade { reuse: Reuse, } +/// The image key of a linked image of the file at `path` that shows `bytes` (the file's pixels or +/// a placeholder): the key of the bytes and the path's. Images of other files that show the same +/// pixels have keys of their own, and reading one file's pixels in changes that file's images only. +fn linked_key(bytes: &[u8], path: &str) -> String { + format!("img-{:016x}-{}", fnv1a(bytes), vectorcraft_doc::links::hash_bytes(path.as_bytes())) +} + impl Importer { pub(super) fn warn(&mut self, s: String) { if !self.warnings.contains(&s) { @@ -721,11 +728,14 @@ impl Importer { .ok() .and_then(|r| r.into_dimensions().ok()) .unwrap_or((size.width().round().max(1.0) as u32, size.height().round().max(1.0) as u32)); - let key = format!("img-{:016x}", fnv1a(bytes)); // A linked file keeps a preview; a placeholder is all a missing one has (its preview). let linked = self.files.linked(i.kind()); let missing = linked.is_some_and(|l| l.missing); let link = linked.map(|l| l.link.clone()); + let key = match &link { + Some(l) => linked_key(bytes, &l.path), + None => format!("img-{:016x}", fnv1a(bytes)), + }; self.doc.images.entry(key.clone()).or_insert_with(|| { let blob = ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: missing.then(|| bytes.clone()) }; if link.is_some() && !missing { blob.with_proxy() } else { blob } diff --git a/crates/svg/tests/it/import_details.rs b/crates/svg/tests/it/import_details.rs index 13518a6fd..7857ba07c 100644 --- a/crates/svg/tests/it/import_details.rs +++ b/crates/svg/tests/it/import_details.rs @@ -105,6 +105,40 @@ fn a_missing_link_warns_and_keeps_a_placeholder_in_its_box() { assert!(!w.is_empty()); } +/// Missing files show the same placeholder, each under a key of its own: the images of one file +/// share a key, and those of another file have a different one. +#[test] +fn each_missing_file_has_its_own_placeholder_key() { + let dir = folder("missing-keys"); + let body = r#""#; + let (d, _) = open_in(&dir, body); + let [(_, a), (_, b), (_, a2)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_eq!(Path::new(&a.link.as_ref().unwrap().path), dir.join("img").join("a.png")); + assert_eq!(Path::new(&b.link.as_ref().unwrap().path), dir.join("img").join("b.png")); + assert_ne!(a.key, b.key, "two files, two keys"); + assert_eq!(a.key, a2.key, "one file, one key"); + assert!(d.images[&a.key].is_proxy() && d.images[&b.key].is_proxy(), "placeholders, not the files"); + assert_eq!(d.images[&a.key].bytes, d.images[&b.key].bytes, "the same placeholder"); +} + +/// Two files with the same pixels are linked under keys of their own: the images of one file share +/// a key, and those of the other file have a different one. +#[test] +fn files_with_the_same_pixels_have_keys_of_their_own() { + let dir = folder("same-pixels"); + let bytes = png(40, 20); + std::fs::write(dir.join("img").join("a.png"), &bytes).unwrap(); + std::fs::write(dir.join("img").join("b.png"), &bytes).unwrap(); + let body = r#""#; + let (d, w) = open_in(&dir, body); + assert!(w.is_empty(), "{w:?}"); + let [(_, a), (_, b), (_, a2)] = images(&d)[..] else { panic!("{:?}", art(&d)) }; + assert_ne!(a.key, b.key, "two files, two keys"); + assert_eq!(a.key, a2.key, "one file, one key"); + assert_eq!(*d.images[&a.key].bytes, bytes); + assert_eq!(*d.images[&b.key].bytes, bytes); +} + #[test] fn svg_images_import_as_vector_art() { let dir = folder("svgimage"); diff --git a/crates/text/src/hyphen.rs b/crates/text/src/hyphen.rs index ffb9572e3..e29334627 100644 --- a/crates/text/src/hyphen.rs +++ b/crates/text/src/hyphen.rs @@ -501,12 +501,17 @@ mod tests { assert_eq!(hyphen_points_with("language", &opts(&|o| o.capitalized = false)), vec![3]); } - /// Process-wide store used by `prefs.set` / restore. + /// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type + /// again. #[test] fn set_hyphenation_exceptions_round_trips_the_source_string() { + let inputs = crate::layout::envelope_ink_inputs(); set_hyphenation_exceptions("typography, hap-pen"); assert_eq!(hyphenation_exceptions(), "typography, hap-pen"); + let changed = crate::layout::envelope_ink_inputs(); + assert_ne!(changed, inputs, "the envelope inputs change with the exceptions"); set_hyphenation_exceptions(""); assert!(hyphenation_exceptions().is_empty()); + assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared"); } } diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index c36514eef..1ff72bdd2 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1,6 +1,7 @@ //! Line breaking and glyph placement for point, area and on-path type. use std::ops::Range; +use std::sync::Arc; use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level}; @@ -13,13 +14,13 @@ use vectorcraft_geom::{ArcPath, PathData}; use crate::GlyphBend; use crate::composer::{Breakpoint, Params, compose}; -use crate::fontdb::FontDb; +use crate::fontdb::{FontDb, FontFace}; use crate::hyphen::points_for; use crate::justify::{Flex, LineFit, apply_desired, fit_line, is_word_space}; use crate::kashida; use crate::shape::{ - Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, shape_range, soft_line_start, - style_metrics, + Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, mark_word_ends, no_line_end, no_line_start, punct, run_face, shape_range, shape_runs, + soft_line_start, style_metrics, }; use crate::{Composer, FirstBaseline, InlineGlyph, LayoutOptions, LineInfo, ListMarker, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign}; @@ -36,9 +37,15 @@ pub(crate) use bidi::{is_rtl, para_bidi}; use breaking::tab_advance; #[cfg(test)] use breaking::{break_line, candidates}; +#[cfg(test)] +pub(crate) use driver::layout_faces_per_call; pub use driver::{decorations, fit_first_line, layout, layout_with, scaled}; use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths}; +#[cfg(test)] +pub(crate) use driver::envelope_ink_inputs; +pub use driver::{envelope_ink, register_envelope_ink}; + const EPS: f64 = 1e-6; /// Is glyph `g` a later glyph of the cluster of glyph `prev` before it (a combining mark the shaper @@ -108,6 +115,8 @@ struct Ctx<'a> { runs: Vec<(Range, &'a CharStyle)>, /// The inline graphic of each run that is one (parallel to `runs`). inlines: Vec>, + /// The face of each run (parallel to `runs`), looked up once ([`run_face`]). + faces: Vec>>>, default: CharStyle, opts: &'a LayoutOptions, /// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned @@ -126,13 +135,26 @@ struct Ctx<'a> { } impl Ctx<'_> { - fn style_at(&self, b: usize) -> &CharStyle { + /// The index of the run whose style [`Self::style_at`] gives for byte `b`; `None` without runs. + fn run_at(&self, b: usize) -> Option { // The runs follow one another: the first that ends after `b` is the only one that can hold it. let find = |b: usize| { let i = self.runs.partition_point(|(r, _)| r.end <= b); - self.runs.get(i).filter(|(r, _)| r.start <= b).map(|(_, s)| *s) + self.runs.get(i).filter(|(r, _)| r.start <= b).map(|_| i) }; - find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default) + find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| (!self.runs.is_empty()).then_some(0)) + } + + fn style_at(&self, b: usize) -> &CharStyle { + self.run_at(b).and_then(|i| self.runs.get(i)).map_or(&self.default, |(_, s)| *s) + } + + /// The face of [`Self::style_at`] `b` ([`FontDb::style_face`]). + fn face_at(&self, b: usize) -> Option> { + match self.run_at(b).and_then(|i| self.runs.get(i).map(|(_, s)| (i, *s))) { + Some((i, st)) => run_face(self.db, &self.faces, i, st), + None => self.db.style_face(&self.default), + } } /// The stretches of paragraph `r` whose characters have a Character Direction, as byte ranges @@ -158,7 +180,7 @@ impl Ctx<'_> { fn shape_para(&self, r: Range, bidi: Option<&BidiInfo<'_>>) -> Vec { let mut v = Vec::with_capacity(r.len()); let levels = bidi.map_or(&[][..], |b| &b.levels); - shape_range(self.db, self.text, r, &self.runs, &self.inlines, &self.opts.features, levels, &mut v); + shape_runs(self.db, self.text, r, &self.runs, &self.inlines, &self.faces, &self.opts.features, levels, &mut v); self.finish_shaping(&mut v, |g| self.style_at(g.byte)); v } diff --git a/crates/text/src/layout/driver.rs b/crates/text/src/layout/driver.rs index fac55c5d6..f55750e95 100644 --- a/crates/text/src/layout/driver.rs +++ b/crates/text/src/layout/driver.rs @@ -112,6 +112,62 @@ pub fn decorations(layout: &TextLayout, db: &FontDb, t: &TextObject) -> Vec<(usi out } +/// The box of what `t` draws with text space mapped by `m`: its glyph outlines +/// ([`TextLayout::ink_bounds_in`]) and its underline and strikethrough bars ([`decorations`]). With +/// `projection` `(h, after)`, each point `m` maps is then drawn through `h` (a point `h` can't map +/// stays where it is, as the renderer draws it) and mapped by `after`; the outlines and bars are +/// flattened to within 1/1000 of their size, and images, color glyphs and inline graphics count by +/// their boxes' corners. What an envelope maps type from ([`register_envelope_ink`]). `None` when +/// nothing draws. +pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_geom::Homography, Affine)>) -> Option { + let db = FontDb::global(); + let laid = layout(db, t); + let bars = decorations(&laid, db, t).into_iter().map(|(_, bar)| bar); + let union = |acc: Option, b: Rect| Some(acc.map_or(b, |a| a.union(b))); + let Some((h, after)) = projection else { + return bars.map(|bar| (m * bar).bounding_box()).fold(laid.ink_bounds_in(m), union); + }; + let draw = |p: Point| { + let q = m * p; + after * h.apply(q).unwrap_or(q) + }; + let add = |acc: Option, p: Point| union(acc, Rect::from_points(p, p)); + let projected = |path: &BezPath| { + let size = path.bounding_box(); + let tolerance = (size.width().max(size.height()) / 1000.0).max(1e-6); + let mut points = vec![]; + kurbo::flatten(path, tolerance, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + points.push(draw(p)); + } + }); + points.into_iter().fold(None, add) + }; + let corners = |r: Rect| { + [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(draw).into_iter().fold(None, add) + }; + let glyphs = laid.glyphs.iter().chain(&laid.ruby).filter_map(|g| projected(&g.outline)); + let boxes = laid.glyphs.iter().flat_map(|g| { + let image = g.placed_image().map(|(image, xf)| xf.transform_rect_bbox(image.rect())); + let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| g.xf.transform_rect_bbox(b)); + [image, colour] + }); + let boxes = boxes.chain(laid.inlines.iter().map(|i| Some(i.bounds))).flatten().filter_map(corners); + glyphs.chain(bars.filter_map(|bar| projected(&bar))).chain(boxes).fold(None, union) +} + +/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation +/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`). +pub fn register_envelope_ink() { + vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs); +} + +/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the +/// installed fonts and the hyphenation exceptions. +pub(crate) fn envelope_ink_inputs() -> u64 { + FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation() +} + /// Most layout passes Shrink Text to Fit runs past the first (bisection plus the final pass). const SHRINK_PASSES: usize = 11; @@ -204,6 +260,22 @@ pub fn scaled(t: &TextObject, f: f64) -> TextObject { s } +#[cfg(test)] +thread_local! { + /// Set while [`layout_faces_per_call`] lays text out: layout keeps no run faces. + static FACES_PER_CALL: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// [`layout`] keeping no run faces, so that every lookup of a face asks [`FontDb::style_face`] +/// again: the reference the faces kept per run are compared with. +#[cfg(test)] +pub(crate) fn layout_faces_per_call(db: &FontDb, t: &TextObject) -> TextLayout { + FACES_PER_CALL.set(true); + let l = layout(db, t); + FACES_PER_CALL.set(false); + l +} + /// One layout pass, without fitting. fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout { let text = t.plain_text(); @@ -234,10 +306,14 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout } else { opts }; + let faces = runs.iter().map(|_| std::cell::OnceCell::new()).collect(); + #[cfg(test)] + let faces = if FACES_PER_CALL.get() { Vec::new() } else { faces }; let mut cx = Ctx { on_path: is_on_path, db, text: &text, + faces, runs, inlines, default: CharStyle::default(), @@ -256,7 +332,7 @@ fn layout_once(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout TextKind::OnPath { .. } => Affine::IDENTITY, TextKind::Point => { let first = cx.style_at(0); - let centre = db.style_face(first).map_or(EM_CENTER, |f| f.ideographic_centre()); + let centre = cx.face_at(0).map_or(EM_CENTER, |f| f.ideographic_centre()); Affine::translate((-centre * first.size, 0.0)) * QUARTER_TURN } _ => QUARTER_TURN, diff --git a/crates/text/src/layout/flow.rs b/crates/text/src/layout/flow.rs index 112dabb23..79cc79ba6 100644 --- a/crates/text/src/layout/flow.rs +++ b/crates/text/src/layout/flow.rs @@ -232,11 +232,12 @@ fn prepare_paragraph<'a, 'text>( } } let pm = { - let (asc, desc, _) = style_metrics(cx.db, cx.style_at(pr.start)); + let face = cx.face_at(pr.start); + let (asc, desc, _) = style_metrics(face.as_deref(), cx.style_at(pr.start)); let lead = auto_leading(cx.style_at(pr.start)); let style = cx.style_at(pr.start); - let (cap, xh) = cap_x_heights(cx.db, style); - let centre = cx.db.style_face(style).map_or(EM_CENTER, |face| face.ideographic_centre()); + let (cap, xh) = cap_x_heights(face.as_deref(), style); + let centre = face.map_or(EM_CENTER, |face| face.ideographic_centre()); Metrics { asc, desc, lead, cap, xh, top: (centre + 0.5) * style.size * style.v_scale / 100.0 } }; if pi > 0 { diff --git a/crates/text/src/layout/on_path.rs b/crates/text/src/layout/on_path.rs index ddf4e56f3..14bc3ef4b 100644 --- a/crates/text/src/layout/on_path.rs +++ b/crates/text/src/layout/on_path.rs @@ -170,7 +170,7 @@ pub(super) fn on_path(cx: &mut Ctx<'_>, paras: &[Range], t: &TextObject, // A path out of range holds no text, as a path with no length holds none. let ap = if in_range(&path.to_bezpath()) { ArcPath::new(path) } else { ArcPath::default() }; let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| { - let s = style_metrics(cx.db, cx.style_at(0)); + let s = style_metrics(cx.face_at(0).as_deref(), cx.style_at(0)); (s.0, s.1) }); if ap.is_empty() { diff --git a/crates/text/src/layout/wrapping_tests.rs b/crates/text/src/layout/wrapping_tests.rs index 413ea72d7..aec815641 100644 --- a/crates/text/src/layout/wrapping_tests.rs +++ b/crates/text/src/layout/wrapping_tests.rs @@ -115,6 +115,7 @@ fn composition_trim_stays_along_the_line_when_a_character_rotates() { text: "A", runs: vec![(0..1, &style)], inlines: vec![None], + faces: vec![std::cell::OnceCell::new()], default: style.clone(), opts: &opts, vertical: false, diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 8b1c4a63d..d7bc1d444 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -41,6 +41,7 @@ pub use fontdb::{ style_weight, system_font_dirs, user_font_dirs, }; pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions}; +pub use layout::{envelope_ink, register_envelope_ink}; use std::sync::Arc; pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader}; @@ -440,6 +441,20 @@ impl TextLayout { let inlines = self.inlines.iter().map(|i| Some(i.bounds)); glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b)) } + /// The box of [`Self::ink_bounds`]' ink with text space mapped by `m`: the glyph and ruby + /// outlines are mapped point by point, so a rotated or sheared `m` gives the outlines' own + /// bounds. Images, color glyphs and inline graphics count by their mapped boxes. + pub fn ink_bounds_in(&self, m: Affine) -> Option { + let outlined = |g: &PositionedGlyph| (!g.outline.elements().is_empty()).then(|| (m * &g.outline).bounding_box()); + let glyphs = self.glyphs.iter().flat_map(|g| { + let image = g.placed_image().map(|(image, xf)| (m * xf).transform_rect_bbox(image.rect())); + let colour = g.colour.as_ref().and_then(|c| c.geometric_bounds()).map(|b| (m * g.xf).transform_rect_bbox(b)); + [outlined(g), image, colour] + }); + let ruby = self.ruby.iter().map(outlined); + let inlines = self.inlines.iter().map(|i| Some(m.transform_rect_bbox(i.bounds))); + glyphs.chain(ruby).chain(inlines).flatten().reduce(|a, b| a.union(b)) + } /// All glyph outlines combined (e.g. for Create Outlines). pub fn to_bezpath(&self) -> BezPath { let mut p = BezPath::new(); @@ -834,6 +849,16 @@ pub fn logical_order(visual: &str) -> Option<(Vec, bool)> { Some((unicode_bidi::BidiInfo::reorder_visual(&levels), para.level.is_rtl())) } +/// Line metrics of the font at `index` of `data`, at its default axis settings, in font units: +/// (units per em, ascent, descent (negative below the baseline), line gap). egui builds its text +/// rows from these values. `None` if the font can't be read. +pub fn line_metrics(data: &[u8], index: u32) -> Option<(f32, f32, f32, f32)> { + use skrifa::MetadataProvider; + let f = skrifa::FontRef::from_index(data, index).ok()?; + let m = f.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()); + Some((f32::from(m.units_per_em.max(1)), m.ascent, m.descent, m.leading)) +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/text/src/shape.rs b/crates/text/src/shape.rs index 1a6716c5f..88f9a1f39 100644 --- a/crates/text/src/shape.rs +++ b/crates/text/src/shape.rs @@ -324,20 +324,33 @@ pub(crate) fn is_cjk(c: char) -> bool { matches!(c as u32, 0x2E80..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF | 0xFF00..=0xFFEF | 0x20000..=0x2FFFF) } -/// Vertical metrics (points) of a style's resolved face: (ascent, descent, leading). -pub(crate) fn style_metrics(db: &FontDb, st: &CharStyle) -> (f64, f64, f64) { +/// The faces of a text's runs ([`FontDb::style_face`] of each run's style), each looked up the first +/// time layout asks for it and kept for the rest of that layout. +pub(crate) type RunFaces = [std::cell::OnceCell>>]; + +/// The face of run `ri`, whose style is `st`: kept in `faces` when it has a place for the run, +/// otherwise looked up. +pub(crate) fn run_face(db: &FontDb, faces: &RunFaces, ri: usize, st: &CharStyle) -> Option> { + match faces.get(ri) { + Some(face) => face.get_or_init(|| db.style_face(st)).clone(), + None => db.style_face(st), + } +} + +/// Vertical metrics (points) of style `st` in its resolved face `face`: (ascent, descent, leading). +pub(crate) fn style_metrics(face: Option<&FontFace>, st: &CharStyle) -> (f64, f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.style_face(st) else { + let Some(face) = face else { return (st.size * 0.8 * vs, st.size * 0.2 * vs, st.effective_leading()); }; let k = st.size / face.upem; (face.ascent * k * vs, face.descent * k * vs, st.effective_leading()) } -/// Cap height and x height (points) of a style's resolved face. -pub(crate) fn cap_x_heights(db: &FontDb, st: &CharStyle) -> (f64, f64) { +/// Cap height and x height (points) of style `st` in its resolved face `face`. +pub(crate) fn cap_x_heights(face: Option<&FontFace>, st: &CharStyle) -> (f64, f64) { let vs = st.v_scale / 100.0; - let Some(face) = db.style_face(st) else { + let Some(face) = face else { return (st.size * 0.7 * vs, st.size * 0.5 * vs); }; let k = st.size / face.upem * vs; @@ -358,6 +371,23 @@ pub(crate) fn shape_range( feats: &OtFeatures, levels: &[Level], out: &mut Vec, +) { + shape_runs(db, text, range, runs, inlines, &[], feats, levels, out); +} + +/// [`shape_range`] with `faces`, parallel to `runs`, holding the face of each run +/// ([`run_face`]). +#[allow(clippy::too_many_arguments)] +pub(crate) fn shape_runs( + db: &FontDb, + text: &str, + range: Range, + runs: &[(Range, &CharStyle)], + inlines: &[Option<&InlineArt>], + faces: &RunFaces, + feats: &OtFeatures, + levels: &[Level], + out: &mut Vec, ) { if text.get(range.clone()).is_none() || range.is_empty() { return; @@ -409,12 +439,13 @@ pub(crate) fn shape_range( let a = bytes.start; let b = bytes.end; if let Some(art) = art { - if let Some(face) = db.style_face(st).or_else(|| db.face_covering('a')) { + if let Some(face) = run_face(db, faces, source_runs.start, st).or_else(|| db.face_covering('a')) { out.push(inline_glyph(&face, st, art, source_runs.start, a..b, text, level_at(a), feats.vertical)); } continue; } - let Some(primary) = db.style_face(st) else { continue }; + // A span's style is the style of its first source run. + let Some(primary) = run_face(db, faces, source_runs.start, st) else { continue }; let pmap = primary.skrifa().map(|f| f.charmap()); // Synthesized Small Caps shape lowercase letters separately (as smaller capitals). let small_caps = st.small_caps.is_some() && !st.all_caps; diff --git a/crates/text/src/test_fonts.rs b/crates/text/src/test_fonts.rs index 1f74e2213..2f591b6e0 100644 --- a/crates/text/src/test_fonts.rs +++ b/crates/text/src/test_fonts.rs @@ -1007,6 +1007,34 @@ fn remapped_source_sans(family: &str, postscript: &str, mapping: &[(char, char)] Some(builder.build()) } +/// The family of [`tall_ideograph_font`]. +pub const TALL_IDEOGRAPH_FAMILY: &str = "Tall Ideograph Sans"; + +/// A face that maps the ideograph 编 to the bundled Source Sans 3 `H` outline, which stands on the +/// baseline, with a taller ascent (1,058 units per 1,000) and descent (262) than Source Sans 3 in +/// its `hhea` and `OS/2` tables and no line gap: the vertical proportions of a CJK system face. +pub fn tall_ideograph_font() -> Option> { + const ASCENT: i16 = 1058; + const DESCENT: i16 = 262; + let mut data = remapped_source_sans(TALL_IDEOGRAPH_FAMILY, "TallIdeographSans-Regular", &[('编', 'H')])?; + let tables: Vec<(Tag, usize)> = { + let font = skrifa::FontRef::new(&data).ok()?; + font.table_directory.table_records().iter().map(|r| (Tag::new(&r.tag().to_be_bytes()), r.offset() as usize)).collect() + }; + let (ascent, descent, gap) = (ASCENT.to_be_bytes(), (-DESCENT).to_be_bytes(), [0, 0]); + for (tag, at) in tables { + if tag == Tag::new(b"hhea") { + // ascender, descender and lineGap at bytes 4 to 10. + data.get_mut(at + 4..at + 10)?.copy_from_slice(&[ascent, descent, gap].concat()); + } else if tag == Tag::new(b"OS/2") { + // sTypoAscender, sTypoDescender, sTypoLineGap, usWinAscent and usWinDescent at bytes 68 to 78. + let win = [ASCENT.unsigned_abs().to_be_bytes(), DESCENT.unsigned_abs().to_be_bytes()]; + data.get_mut(at + 68..at + 78)?.copy_from_slice(&[ascent, descent, gap, win[0], win[1]].concat()); + } + } + Some(data) +} + /// The family of [`soft_hyphen_outline_font`]. pub const SOFT_HYPHEN_OUTLINE_FAMILY: &str = "Soft Hyphen Profile Sans"; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8a3371e8f..6183278ed 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -558,6 +558,100 @@ fn multiple_runs_and_styles() { assert_ne!(l.glyphs[0].font_id, l.glyphs[3].font_id); } +/// Each run's glyphs are set in its own style's face, and each empty paragraph's line takes the +/// ascent and descent of its own style's face: families mixed in a paragraph, a missing family that +/// falls back, a style the family does not have, and a change of family in the middle of a +/// paragraph. +#[test] +fn each_run_and_paragraph_takes_its_own_face() { + let styles = [ + serif(30.0), + CharStyle { font_family: "No Such Family".into(), font_style: "Bold".into(), ..style(10.0) }, + CharStyle { font_family: "Inter".into(), font_style: "Bold Italic".into(), ..style(20.0) }, + CharStyle { font_family: "JetBrains Mono".into(), ..style(14.0) }, + style(16.0), + ]; + let run = |text: &str, i: usize| TextRun::new(text, styles[i].clone()); + let mut t = point("", style(10.0)); + t.runs = vec![run("Ab", 0), run("cd\n", 1), run("\n", 2), run("\n", 3), run("xy", 1), run("zw", 4), run("Qr", 2)]; + let l = layout(db(), &t); + let faces: Vec<_> = styles.iter().map(|st| db().style_face(st).unwrap()).collect(); + let face_of_run = [0, 1, 2, 3, 1, 4, 2]; + let text = t.plain_text(); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| text.get(g.byte..).is_some_and(|s| s.starts_with(char::is_alphabetic))).collect(); + assert_eq!(drawn.len(), 10); + for g in drawn { + assert_eq!(g.font_id, faces[face_of_run[g.run]].id(), "run {}", g.run); + } + assert_eq!(l.lines.len(), 4); + for (line, i) in [(1, 2), (2, 3)] { + let (face, size) = (&faces[i], styles[i].size); + let (ascent, descent) = (face.ascent * size / face.upem, face.descent * size / face.upem); + assert!((l.lines[line].ascent - ascent).abs() < 1e-9, "line {line}: {} versus {ascent}", l.lines[line].ascent); + assert!((l.lines[line].descent - descent).abs() < 1e-9, "line {line}: {} versus {descent}", l.lines[line].descent); + } + assert_ne!(faces[2].id(), faces[3].id()); +} + +/// Faces kept per run lay text out exactly as looking each face up again at every use does +/// ([`crate::layout::layout_faces_per_call`]): the same glyphs, positions, bounds and baselines, +/// with mixed families, a missing family that falls back, Bold and Italic asked of families +/// without those styles (their closest style stands in), and families that change in the middle of +/// a paragraph, in point, vertical, area and path type. +#[test] +fn faces_kept_per_run_lay_out_as_faces_looked_up_at_each_use() { + let styles = [ + serif(24.0), + CharStyle { font_family: "No Such Family".into(), ..style(12.0) }, + CharStyle { font_family: "Source Serif 4".into(), font_style: "Bold".into(), ..style(18.0) }, + CharStyle { font_family: "Inter".into(), font_style: "Italic".into(), ..style(20.0) }, + CharStyle { font_family: "JetBrains Mono".into(), font_style: "Bold Italic".into(), ..style(14.0) }, + style(16.0), + ]; + let matched = [(1, FontMatch::Missing), (2, FontMatch::Style), (3, FontMatch::Style), (4, FontMatch::Style)]; + for (i, m) in matched { + let st = &styles[i]; + assert_eq!(db().resolve(&st.font_family, &st.font_style).map(|(_, m)| m), Some(m), "{} {}", st.font_family, st.font_style); + } + let style_of_run = [0, 1, 2, 3, 4, 2, 4, 5, 3, 1]; + let texts = ["Ab ", "cd ef\n", "\n", "gh ", "ij", " kl\n", "\n", "mn ", "op ", "qr"]; + let runs: Vec<_> = texts.into_iter().zip(style_of_run).map(|(text, i)| TextRun::new(text, styles[i].clone())).collect(); + let mut horizontal = point("", style(10.0)); + horizontal.runs = runs; + let mut vertical = horizontal.clone(); + vertical.vertical = true; + let mut wrapped = area("", style(10.0), Rect::new(0.0, 0.0, 60.0, 400.0), Justify::Left); + wrapped.runs = horizontal.runs.clone(); + let mut on_path = horizontal.clone(); + let path = kurbo::Line::new((0.0, 0.0), (400.0, 60.0)).to_path(0.1); + on_path.kind = TextKind::OnPath { path: PathData::from_bezpath(&path), start: 0.0, end: None }; + let mut empty_path = on_path.clone(); + empty_path.runs = vec![TextRun::new("", styles[1].clone())]; + let glyph = |g: &PositionedGlyph| { + let b = g.outline.bounding_box(); + (g.font_id, g.gid, g.run, g.byte, g.line, [g.origin.x, g.origin.y, g.advance, g.width, b.x0, b.y0, b.x1, b.y1], g.xf.as_coeffs()) + }; + let line = |l: &LineInfo| [l.baseline, l.first_baseline, l.ascent, l.descent, l.x0, l.x1]; + for (name, t) in [("point", &horizontal), ("vertical", &vertical), ("area", &wrapped), ("path", &on_path), ("empty path", &empty_path)] { + let kept = layout(db(), t); + let looked_up = crate::layout::layout_faces_per_call(db(), t); + assert_eq!(kept.glyphs.iter().map(glyph).collect::>(), looked_up.glyphs.iter().map(glyph).collect::>(), "{name}: glyphs"); + assert_eq!(kept.lines.iter().map(line).collect::>(), looked_up.lines.iter().map(line).collect::>(), "{name}: lines"); + assert_eq!(kept.bounds, looked_up.bounds, "{name}: bounds"); + assert_eq!(kept.line_xf, looked_up.line_xf, "{name}: line transform"); + assert_eq!(kept.overflow, looked_up.overflow, "{name}: overflow"); + } + // The stand-in faces set the glyphs of the runs that ask for a missing family or style. + let l = layout(db(), &horizontal); + let text = horizontal.plain_text(); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| text.get(g.byte..).is_some_and(|s| s.starts_with(char::is_alphabetic))).collect(); + assert_eq!(drawn.len(), 18); + for g in drawn { + assert_eq!(Some(g.font_id), db().style_face(&styles[style_of_run[g.run]]).map(|f| f.id()), "run {}", g.run); + } + assert_eq!(l.lines.len(), 5); +} + #[test] fn many_paragraphs_of_their_own_runs_lay_out_in_linear_time() { // 20,000 one-letter paragraphs, each a run of its own: every paragraph looking through every diff --git a/crates/text/src/tests/typo.rs b/crates/text/src/tests/typo.rs index 8f37e5905..4791347d0 100644 --- a/crates/text/src/tests/typo.rs +++ b/crates/text/src/tests/typo.rs @@ -704,12 +704,12 @@ fn area_flow_moves_a_word_down_to_a_line_wide_enough_for_it() { } /// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or -/// takes more than 20 s. +/// has not ended after 300 s. fn layout_soon(t: &TextObject) -> TextLayout { let (tx, rx) = std::sync::mpsc::channel(); let (t, text) = (t.clone(), t.plain_text()); std::thread::spawn(move || tx.send(layout(db(), &t)).ok()); - rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap_or_else(|e| panic!("laying out {text:?}: {e}")) + rx.recv_timeout(std::time::Duration::from_secs(300)).unwrap_or_else(|e| panic!("laying out {text:?}: {e}")) } /// Each line's baseline, left and right edges, and byte range. diff --git a/crates/tools/src/context.rs b/crates/tools/src/context.rs index 85b727222..835303b44 100644 --- a/crates/tools/src/context.rs +++ b/crates/tools/src/context.rs @@ -242,6 +242,9 @@ pub struct ToolContext<'a> { /// object shows how far the selection is from it, and over an empty part of an artboard how /// far it is from the artboard's edges ([`guides::distance_guides`]). pub distance_guides: bool, + /// Smart Guides → Snap to Last Location: a selection moved by dragging goes back to where the + /// move began while it is within Snapping Tolerance of it ([`guides::Targets::snap_back`]). + pub snap_to_last_location: bool, /// Smart Guides → Snapping Tolerance (screen pixels): how near a smart guide target pulls the /// pointer, a dragged edge or a drawn point. pub snapping_tolerance: f64, diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 2b2f04c83..d27a9f2a4 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -60,7 +60,7 @@ use crate::guides::{HandleSnap, Leave, PointSnap, Targets}; use crate::meshedit::{MeshEdit, grid_of}; use crate::pathtype::{self, BracketDrag, near_bracket}; use crate::rulerguide::GuideEdit; -use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at}; +use crate::select::{MoveSnap, hit_center, hold_point, is_area_type, is_path_type, matrix_json, release_guide_at, selection_bounds}; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; #[derive(Clone, Debug)] @@ -200,6 +200,10 @@ pub struct DirectSelectionTool { /// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left /// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original. copy_snap: Option, + /// With Smart Guides › Snap to Last Location, for paths selected whole that an anchor holds + /// while they move: the targets that put the move back where it began, and the bounds it began + /// with. Set when the move begins. + last_location: Option<(Targets, Option)>, /// What the dragged handle snaps to. handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. @@ -249,6 +253,7 @@ impl DirectSelectionTool { anchor_snap: None, line_end: None, copy_snap: None, + last_location: None, handle_snap: HandleSnap::default(), move_snap: None, partial_snap: None, @@ -724,6 +729,7 @@ impl Tool for DirectSelectionTool { self.state = State::Idle; self.guides.clear(); (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); + self.last_location = None; (self.click, self.click_segment, self.click_group, self.frame_click) = (None, None, None, None); if frame { vec![Action::Cancel, Action::Notify(FRAME_PICKED.into())] } else { vec![Action::Cancel] } } @@ -1440,6 +1446,9 @@ impl DirectSelectionTool { // Alt can go down or up at any time: gather both while nothing has moved. self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + // As a move with the Selection tool does ([`MoveSnap::new`]). + let back = cx.smart_guides && !cx.snap_to_grid && cx.snap_to_last_location; + self.last_location = back.then(|| (Targets::last_location(cx), selection_bounds(cx))); } else { self.move_snap = Some(MoveSnap::new(cx)); } @@ -1463,7 +1472,10 @@ impl DirectSelectionTool { return out; } let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; - if let (Some(grab), Some(snap)) = (grab, snap) { + let back = self.last_location.as_ref().filter(|_| !ev.mods.alt).and_then(|(t, from)| t.snap_back(*from, d, cx.snap_tol())); + if let (Some(_), Some(ov)) = (grab, back) { + (d, self.guides) = (Vec2::ZERO, ov); + } else if let (Some(grab), Some(snap)) = (grab, snap) { let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); (d, self.guides) = (q - grab, guides); @@ -1487,6 +1499,7 @@ impl DirectSelectionTool { self.state = State::Idle; self.guides.clear(); (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); + self.last_location = None; let group = self.click_group.take(); // A click on one of several selected frame anchors selects it alone. if let (false, Some((id, anchor))) = (began, self.frame_click.take()) { diff --git a/crates/tools/src/direct/tests.rs b/crates/tools/src/direct/tests.rs index c249bd40a..a8507e693 100644 --- a/crates/tools/src/direct/tests.rs +++ b/crates/tools/src/direct/tests.rs @@ -542,6 +542,73 @@ fn pressing_an_anchor_of_a_fully_selected_path_keeps_the_selection() { assert_eq!(a, vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[0, 0]], "mode": "toggle"}))]); } +/// Smart Guides › Snap to Last Location: a path selected whole that Direct Selection moves held by +/// one of its anchors goes back to where it began within Snapping Tolerance, labeled "last +/// location", a move Shift keeps to its line too; farther out, or with the option off, it moves as +/// dragged. +#[test] +fn snap_to_last_location_puts_a_path_moved_by_its_anchor_back() { + let (mut d, id) = doc_with_rect(); + // An artboard whose edges and center are out of reach. + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let on = ToolContext { snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + // A press on the top-left anchor, then a drag to `to` with `m` held. + let moved = |c: &ToolContext, to: (f64, f64), m: Mods| { + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(c, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); + let a = t.pointer(c, &PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m)); + let Some(Action::Preview(command, v)) = a.last() else { panic!("{a:?}") }; + assert_eq!(command, "object.transform"); + let labels: Vec = + t.overlays(c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + (Vec2::new(v["matrix"][4].as_f64().unwrap_or(f64::NAN), v["matrix"][5].as_f64().unwrap_or(f64::NAN)), labels) + }; + let none = Mods::default(); + let (back, labels) = moved(&on, (106.0, 105.0), none); + assert!(back.hypot() < 1e-9, "{back:?}: 7.8 pt away, within 10"); + assert_eq!(labels, ["last location"]); + let shift = Mods { shift: true, ..none }; + let (back, _) = moved(&on, (107.0, 100.0), shift); + assert!(back.hypot() < 1e-9, "{back:?}: a Shift move goes back too"); + let (out, labels) = moved(&on, (109.0, 108.0), none); + assert!((out - Vec2::new(9.0, 8.0)).hypot() < 1e-9, "{out:?}: 12 pt away, out of reach"); + assert!(labels.is_empty(), "{labels:?}"); + let off = ToolContext { snap_to_last_location: false, ..on }; + let (stays, _) = moved(&off, (106.0, 105.0), none); + assert!((stays - Vec2::new(6.0, 5.0)).hypot() < 1e-9, "{stays:?}: the option off"); +} + +/// View › Snap to Pixel wins over Smart Guides › Snap to Last Location for a path selected whole +/// that Direct Selection moves held by an anchor: a short move puts the held anchor, off the +/// pixel grid before, on whole pixels and shows no "last location" label. +#[test] +fn snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.set([id]); + let p = paint(); + let c = ToolContext { snap_to_pixel: true, snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + let mut t = DirectSelectionTool::new(false); + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 100.3, 100.4)).is_empty()); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 106.3, 105.4)); + let Some(Action::Preview(command, v)) = a.last() else { panic!("{a:?}") }; + assert_eq!(command, "object.transform"); + let m = Vec2::new(v["matrix"][4].as_f64().unwrap_or(f64::NAN), v["matrix"][5].as_f64().unwrap_or(f64::NAN)); + // The held anchor (106.3, 105.4) rounds to (106, 105). + assert!((m - Vec2::new(5.7, 4.6)).hypot() < 1e-9, "{m:?}"); + let labels: Vec = + t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + assert!(labels.is_empty(), "{labels:?}"); +} + /// A click (no drag) on an anchor that is selected with others, whether the whole path is or /// some of its anchors, selects that anchor alone, as in Illustrator: a press keeps the /// selection so a drag moves it all, the release without a drag narrows it. Shift still diff --git a/crates/tools/src/guides/targets.rs b/crates/tools/src/guides/targets.rs index 709e1f53d..17b24ec16 100644 --- a/crates/tools/src/guides/targets.rs +++ b/crates/tools/src/guides/targets.rs @@ -57,6 +57,9 @@ pub struct Targets { pub(super) perpendicular: bool, /// How the guides these targets produce look. pub(super) style: GuideStyle, + /// Smart Guides › Snap to Last Location: a move goes back to where it began when it comes near + /// ([`Self::snap_back`]). Set only for moving the selection itself ([`Self::for_move`]). + pub(super) last_location: bool, } /// How many anchors, and how many segments, the targets gather at most. @@ -163,19 +166,38 @@ impl Targets { /// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection, /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › - /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). + /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). With Smart + /// Guides › Snap to Last Location, a move (not a copy, which has nothing in `exclude`) goes back + /// to where it began when it comes near ([`Self::snap_back`]), unless Snap to Pixel is on. pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { let mut t = Self::collect(cx.doc, exclude, None).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } + t.last_location = cx.snap_to_last_location && !cx.snap_to_pixel && !exclude.is_empty(); t } + /// Smart Guides › Snap to Last Location: a move by `d` that is no farther than `tol` from + /// where it began goes back there. Returns the guides for it, a "last location" label at the + /// center of `from` (the moved bounds when the move began), or None when the move stays where + /// it is: the option is off, the targets are for a copy, or `d` is longer than `tol`. + pub fn snap_back(&self, from: Option, d: Vec2, tol: f64) -> Option> { + (self.last_location && d.hypot() <= tol).then(|| from.and_then(|r| self.label(r.center(), "last location")).into_iter().collect()) + } + + /// Targets that hold nothing but Smart Guides › Snap to Last Location ([`Self::snap_back`]), + /// styled as `cx` asks: for a move of the selection whose other snaps come from other targets, + /// such as paths selected whole that Direct Selection moves held by an anchor. Snap to Pixel + /// turns it off, as it does every Smart Guides snap. + pub fn last_location(cx: &ToolContext) -> Self { + Self { last_location: cx.snap_to_last_location && !cx.snap_to_pixel, ..Self::default() }.styled(cx) + } + /// Targets for scaling the selection: the other art, styled as `cx` asks, and with Smart Guides › /// Spacing Guides the neighbours whose gaps the moving edge should match ([`Self::snap_scale`]). pub fn for_scale(cx: &ToolContext) -> Self { - Self::for_move(cx, &cx.selection.objects) + Self { last_location: false, ..Self::for_move(cx, &cx.selection.objects) } } /// Targets for a drawn point: the art in `visible` (all of it for None) but `exclude`, its diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index c6883c3fd..f77ba9dbf 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -532,6 +532,7 @@ pub(crate) mod testutil { transform_tools_guides: true, spacing_guides: true, distance_guides: true, + snap_to_last_location: false, snapping_tolerance: 4.0, construction_angles: guides::construction_angles(guides::DEFAULT_CONSTRUCTION_ANGLES), screen: None, diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 0f879cb55..6d90e79e3 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -231,9 +231,13 @@ impl MoveSnap { } /// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its - /// guides. + /// guides. With Smart Guides › Snap to Last Location, a move within Snapping Tolerance of where + /// it began goes back there before anything else snaps ([`Targets::snap_back`]). pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) }; + if let Some(ov) = targets.as_ref().and_then(|t| t.snap_back(self.bounds, d, cx.snap_tol())) { + return (Vec2::ZERO, ov); + } let mut guides = vec![]; if cx.snap_to_grid && let Some(b) = self.bounds @@ -291,7 +295,8 @@ impl MoveSnap { /// whole pixel on either axis (the one axis of a level or upright line). The bounds line up /// with the other art ([`Targets::snap_rects_along`]) and the grabbed point lands where the line /// passes an anchor or crosses a ruler guide ([`Targets::snap_point_along`]). A move of zero - /// length does not snap. + /// length does not snap, and Snap to Last Location goes back to where the move began first, as + /// in [`Self::snap`]. pub(crate) fn snap_along(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { let len = d.hypot(); if !len.is_finite() || len <= 1e-9 { @@ -309,6 +314,9 @@ impl MoveSnap { .unwrap_or(0.0) }; let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) }; + if let Some(ov) = targets.as_ref().and_then(|t| t.snap_back(self.bounds, d, cx.snap_tol())) { + return (Vec2::ZERO, ov); + } let mut guides = vec![]; if cx.snap_to_grid && let Some(b) = self.bounds diff --git a/crates/tools/src/select/tests.rs b/crates/tools/src/select/tests.rs index 94fb67786..242b9f928 100644 --- a/crates/tools/src/select/tests.rs +++ b/crates/tools/src/select/tests.rs @@ -782,6 +782,80 @@ fn an_alt_copy_snaps_to_the_original_left_behind() { assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0); } +/// Smart Guides › Snap to Last Location: a move that comes within Snapping Tolerance of where it +/// began goes back there, labeled "last location", a Shift move too; farther out, with the option +/// or Smart Guides off, it stays where it is dragged, and an Alt copy snaps as it does with the +/// option off. +#[test] +fn snap_to_last_location_puts_a_move_back_where_it_began() { + let (mut d, id) = doc_with_rect(); + // An artboard whose edges and center are out of reach. + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let on = ToolContext { snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + let moved = |c: &ToolContext, to: (f64, f64), m: Mods| { + let mut t = SelectionTool::default(); + t.pointer(c, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); + let a = t.pointer(c, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let labels: Vec = + t.overlays(c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + (Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), labels) + }; + let none = Mods::default(); + let (back, labels) = moved(&on, (156.0, 155.0), none); + assert!(moved_by(back, (0.0, 0.0)), "{back:?}: 7.8 pt away, within 10"); + assert_eq!(labels, ["last location"]); + let shift = Mods { shift: true, ..none }; + assert!(moved_by(moved(&on, (157.0, 150.0), shift).0, (0.0, 0.0)), "a Shift move goes back too"); + let (out, labels) = moved(&on, (159.0, 158.0), none); + assert!(moved_by(out, (9.0, 8.0)), "{out:?}: 12 pt away, out of reach"); + assert!(labels.is_empty(), "{labels:?}"); + let off = ToolContext { snap_to_last_location: false, ..on }; + assert!(moved_by(moved(&off, (156.0, 155.0), none).0, (6.0, 5.0)), "the option off"); + let no_guides = ToolContext { smart_guides: false, ..on }; + assert!(moved_by(moved(&no_guides, (156.0, 155.0), none).0, (6.0, 5.0)), "Smart Guides off"); + // An Alt copy lines up with the original it leaves behind, as it does with the option off. + let alt = Mods { alt: true, ..none }; + let (copy, labels) = moved(&on, (156.0, 155.0), alt); + assert_eq!((copy, labels.contains(&"last location".to_string())), (moved(&off, (156.0, 155.0), alt).0, false), "{labels:?}"); +} + +/// View › Snap to Pixel wins over Smart Guides › Snap to Last Location, as over every other +/// snap: a short move of a square whose top-left is off the pixel grid puts that corner on whole +/// pixels, a Shift move on its own axis, and shows no "last location" label. +#[test] +fn snap_to_pixel_wins_over_snap_to_last_location() { + let mut d = vectorcraft_doc::Document::new(500.0, 500.0); + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let l = d.layers[0].id; + let id = d.alloc_id(); + let square = vectorcraft_geom::shapes::rectangle(Rect::new(100.3, 100.4, 200.3, 200.4)); + d.insert(Some(l), 0, vectorcraft_doc::Node::path(id, square, vectorcraft_doc::Appearance::default_art())).unwrap(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let c = ToolContext { snap_to_pixel: true, snap_to_last_location: true, snapping_tolerance: 10.0, ..cx(&d, &s, &p) }; + let moved = |to: (f64, f64), m: Mods| { + let mut t = SelectionTool::default(); + t.pointer(&c, &ev(PointerKind::Down, 150.0, 150.0).with_mods(m)); + let a = t.pointer(&c, &ev(PointerKind::Drag, to.0, to.1).with_mods(m)); + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + let labels: Vec = + t.overlays(&c).into_iter().filter_map(|o| if let Overlay::Label { text, .. } = o { Some(text) } else { None }).collect(); + (Vec2::new(v["matrix"][4].as_f64().unwrap(), v["matrix"][5].as_f64().unwrap()), labels) + }; + // By (6, 5) the top-left (106.3, 105.4) rounds to (106, 105). + let (m, labels) = moved((156.0, 155.0), Mods::default()); + assert!(moved_by(m, (5.7, 4.6)), "{m:?}"); + assert!(labels.is_empty(), "{labels:?}"); + let (m, labels) = moved((157.0, 150.0), Mods { shift: true, ..Mods::default() }); + assert!(moved_by(m, (6.7, 0.0)), "{m:?}: a Shift move"); + assert!(labels.is_empty(), "{labels:?}"); +} + /// A move shows the filled arrow, hollow while it snaps (here B's left side onto A's right /// one); Alt, which leaves a copy behind, the double arrowhead. #[test] diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 0bc29a558..c7a5a1ad4 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -396,6 +396,25 @@ impl TypeTool { out } + /// Stop editing and go back to the tool used before double-clicking into type (or to the + /// Selection tool), and leave the type selected. + fn end_editing(&mut self, cx: &ToolContext) -> Vec { + let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); + let mut out = self.finish(cx); + out.push(Action::SwitchTool(target)); + out + } + + /// Enter types a line break, and Shift+Enter types a forced line break. Cmd+Enter (Ctrl+Enter on + /// Windows and Linux) ends editing as Escape does and keeps the typed text. `Mods` does not tell + /// Ctrl apart from Cmd+Ctrl, so Cmd+Ctrl+Enter on macOS ends editing too. + fn enter(&mut self, cx: &ToolContext, mods: Mods) -> Vec { + if mods.cmd && !mods.shift && !mods.alt { + return self.end_editing(cx); + } + self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None) + } + /// Replace the selection with `insert` (styled `runs` when given) inside the typing session. fn replace(&mut self, cx: &ToolContext, insert: &str, styled: Option>) -> Vec { let Some(id) = self.editing else { return vec![] }; diff --git a/crates/tools/src/text/tests.rs b/crates/tools/src/text/tests.rs index b0494a3e1..4ae1424f6 100644 --- a/crates/tools/src/text/tests.rs +++ b/crates/tools/src/text/tests.rs @@ -1267,6 +1267,29 @@ fn escape_returns_to_previous_tool_when_set() { assert_eq!(acts2.last(), Some(&Action::SwitchTool("selection".into()))); } +/// Cmd+Enter (Ctrl+Enter on Windows and Linux) ends editing as Escape does and types no line +/// break. Ctrl+Enter on macOS, Shift and Alt with it still type one. +#[test] +fn cmd_enter_ends_editing_like_escape() { + let (d, _id, mut tool) = editing("Hello"); + tool.set_option("prevTool", &json!("directSelection")); + let acts = key(&mut tool, &d, ToolKey::Enter, CMD); + assert!(acts.iter().all(|a| !matches!(a, Action::Preview(..))), "no line break: {acts:?}"); + assert_eq!(acts.last(), Some(&Action::SwitchTool("directSelection".into()))); + assert!(!tool.wants_text(), "editing ended"); + // Ctrl+Enter on Windows and Linux, and Cmd+Ctrl+Enter on macOS. + let cmd_ctrl = Mods { ctrl: true, ..CMD }; + let (d, _id, mut tool) = editing("Hello"); + assert_eq!(key(&mut tool, &d, ToolKey::Enter, cmd_ctrl).last(), Some(&Action::SwitchTool("selection".into()))); + let mac_ctrl = Mods { ctrl: true, ..Mods::default() }; + for mods in [mac_ctrl, Mods { shift: true, ..CMD }, Mods { alt: true, ..CMD }] { + let (d, _id, mut tool) = editing("Hello"); + let acts = key(&mut tool, &d, ToolKey::Enter, mods); + assert!(acts.iter().any(|a| matches!(a, Action::Preview(..))), "{mods:?} types a line break: {acts:?}"); + assert!(tool.wants_text(), "{mods:?} keeps editing"); + } +} + /// A click from `tool` at (x, y) → the actions of the press and the release. fn click(tool: &mut TypeTool, c: &ToolContext, x: f64, y: f64) -> Vec { let mut acts = tool.pointer(c, &PointerEvent::new(PointerKind::Down, x, y)); diff --git a/crates/tools/src/text/tool.rs b/crates/tools/src/text/tool.rs index 1e9b1be88..9f97f2372 100644 --- a/crates/tools/src/text/tool.rs +++ b/crates/tools/src/text/tool.rs @@ -228,13 +228,8 @@ impl Tool for TypeTool { // Escape ends editing and goes back to the tool used before double-clicking into type // (or to the Selection tool by default), the type selected: the keyboard is free for // tool shortcuts again (#1686). - ToolKey::Escape => { - let target = self.prev_tool.take().unwrap_or_else(|| "selection".into()); - let mut out = self.finish(cx); - out.push(Action::SwitchTool(target)); - out - } - ToolKey::Enter => self.replace(cx, if mods.shift { "\u{2028}" } else { "\n" }, None), + ToolKey::Escape => self.end_editing(cx), + ToolKey::Enter => self.enter(cx, mods), ToolKey::Tab => self.replace(cx, "\t", None), ToolKey::Backspace | ToolKey::Delete if a != b || story => self.replace(cx, "", None), ToolKey::Backspace if self.caret > 0 => { diff --git a/crates/tools/src/xform/artboard.rs b/crates/tools/src/xform/artboard.rs index 09933d2a4..c3710c1bd 100644 --- a/crates/tools/src/xform/artboard.rs +++ b/crates/tools/src/xform/artboard.rs @@ -648,6 +648,11 @@ impl Tool for ArtboardTool { } } + /// A drag is moving or resizing artboards (Artboard Options › Update While Dragging reads it). + fn transforming(&self) -> bool { + matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })) + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. })); self.end_drag(); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 559f59dda..a74582972 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -171,6 +171,7 @@ impl VectorcraftApp { tick_iteration: None, input_frames: vec![], shortcuts_frames: vec![], + held_tool: Vec::new(), synthetic_injected: false, injected_events: vec![], injected_generation: InputGeneration::default(), @@ -534,8 +535,9 @@ impl VectorcraftApp { if self.ui.status_bar && self.ui.screen_mode < 3 { chrome::status_bar(self, ui); chrome::hint_bar(self, ui); - } else { - // Keeps the previous frame's value valid for menus drawn above, before the bars. + } else if !(self.ui.control_bar && self.ui.control_bar_bottom && self.ui.screen_mode < 2) { + // Keeps the previous frame's value valid for menus drawn above, before the bars. A + // Control bar docked at the bottom has set it to the bar's top. widgets::set_menu_floor(&ctx, ctx.content_rect().bottom()); } if self.ui.toolbar && self.ui.screen_mode < 3 { @@ -556,6 +558,8 @@ impl VectorcraftApp { trace_runs::show(self, &ctx); opening::show(self, &ctx); palette::show(self, &ctx); + // After every panel had its chance to draw in this pass. + crate::panels::three_d::end_stale_drag(self, &ctx); if self.custom_titlebar { titlebar::resize_zones(ui); } diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 982972770..985f98c34 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -370,8 +370,10 @@ impl VectorcraftApp { /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { - // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice. + // The tip of a pen whose eraser end selected the Eraser doesn't undo this choice, and + // neither does letting go of a held tool shortcut. self.pen_end.forget_tool(); + self.held_tool.clear(); self.activate_tool(id, true); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9f5438d50..0793ac9e0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -326,6 +326,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } fill_uncovered(app, ui.ctx(), &painter, &key, &doc, rect, &xf); reproject_frame(app, &painter, &xf, rect, (key.doc, v.rotation)); + if fades_outside_artboards(app) { + fade_outside_artboards(&painter, &xf, &doc); + } if let Some(look) = grid_look.filter(|_| !grids_in_back) { grid(&painter, &xf, doc.grid.spacing, doc.grid.subdivisions, look); } @@ -827,6 +830,116 @@ fn reproject_frame(app: &VectorcraftApp, painter: &egui::Painter, xf: &Xf, rect: } } +/// Does the area outside the artboards draw darker? Artboard Options › Fade Region Outside Artboard +/// darkens it while the Artboard tool is active, and with Update While Dragging off it draws as +/// usual while the tool moves or resizes an artboard. Presentation Mode, exports and prints never +/// draw the fade. +fn fades_outside_artboards(app: &VectorcraftApp) -> bool { + let prefs = &app.session.prefs; + app.ui.screen_mode != 3 + && app.session.tool_id() == "artboard" + && prefs.fade_region_outside_artboard + && (prefs.artboard_update_while_dragging || !app.session.tool_transforming()) +} + +/// The fade's color: black at 40% opacity, which shows each channel under it at 60% of its value. +const FADE_OUTSIDE: Color32 = Color32::from_black_alpha(102); + +/// Fade Region Outside Artboard: over the pasteboard and the art on it, one mesh in [`FADE_OUTSIDE`] +/// that covers the canvas outside the artboards of `doc`, drawn through the view. The mesh is not +/// anti-aliased, so its rectangles meet without seams. +fn fade_outside_artboards(painter: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document) { + let boards: Vec = doc.artboards.iter().map(|a| a.rect).collect(); + let mut mesh = egui::Mesh::default(); + for (n, r) in (0u32..).zip(fade_rects(view_bounds(xf), &boards)) { + for p in xf.quad(r) { + mesh.colored_vertex(p, FADE_OUTSIDE); + } + let a = n * 4; + mesh.add_triangle(a, a + 1, a + 2); + mesh.add_triangle(a, a + 2, a + 3); + } + painter.add(Shape::mesh(mesh)); +} + +/// The most rectangles the fade draws exactly around the artboards. +const FADE_MAX_RECTS: usize = 4096; + +/// The rectangles the fade covers: the parts of `bounds` outside the artboards ([`outside_rects`]), +/// or, when those would be more than [`FADE_MAX_RECTS`], the parts outside the box around all the +/// artboards, at most four. +fn fade_rects(bounds: Rect, boards: &[Rect]) -> Vec { + outside_rects(bounds, boards, FADE_MAX_RECTS).unwrap_or_else(|| { + let all: Vec = boards.iter().copied().reduce(|a, b| a.union(b)).into_iter().collect(); + outside_rects(bounds, &all, FADE_MAX_RECTS).unwrap_or_default() + }) +} + +/// The parts of `bounds` that no rectangle of `boards` covers, as rectangles that do not overlap: +/// for each band between consecutive top and bottom edges, the gaps between the boards that cross +/// it. A gap with the same left and right edges as a gap of the band above extends that gap's +/// rectangle down, so each board edge adds at most a few rectangles. Boards that cross each other +/// can leave many more gaps; past `limit` rectangles it stops and returns `None`. +fn outside_rects(bounds: Rect, boards: &[Rect], limit: usize) -> Option> { + let mut ys: Vec = boards.iter().flat_map(|b| [b.y0, b.y1]).filter(|y| *y > bounds.y0 && *y < bounds.y1).collect(); + ys.extend([bounds.y0, bounds.y1]); + ys.sort_by(f64::total_cmp); + ys.dedup(); + let mut by_top: Vec = boards.iter().copied().filter(|b| b.y0 < bounds.y1 && b.y1 > bounds.y0).collect(); + by_top.sort_by(|a, b| a.y0.total_cmp(&b.y0)); + let mut next = 0; + // The boards that cross the current band, left to right. + let mut active: Vec = vec![]; + let mut out: Vec = vec![]; + // The indices in `out` of the band above's rectangles, left to right. + let mut above: Vec = vec![]; + for band in ys.windows(2) { + let &[y0, y1] = band else { continue }; + while let Some(b) = by_top.get(next).copied().filter(|b| b.y0 <= y0) { + let at = active.partition_point(|a| a.x0 <= b.x0); + active.insert(at, b); + next += 1; + } + active.retain(|b| b.y1 >= y1); + let mut gaps = vec![]; + let mut x = bounds.x0; + for b in &active { + let (a, e) = (b.x0.max(bounds.x0), b.x1.min(bounds.x1)); + if a >= e { + continue; + } + if a > x { + gaps.push((x, a)); + } + x = x.max(e); + } + if x < bounds.x1 { + gaps.push((x, bounds.x1)); + } + let mut prev = above.iter().copied().peekable(); + let mut row = Vec::with_capacity(gaps.len()); + for (a, e) in gaps { + while prev.next_if(|&i| out[i].x0 < a).is_some() {} + match prev.peek().copied() { + Some(i) if out[i].x0 == a && out[i].x1 == e => { + out[i].y1 = y1; + row.push(i); + prev.next(); + } + _ => { + row.push(out.len()); + out.push(Rect::new(a, y0, e, y1)); + } + } + } + above = row; + if out.len() > limit { + return None; + } + } + Some(out) +} + /// Do the artboards show? View › Hide Artboards hides them, except for the Artboard tool, which /// edits them. fn shows_artboards(app: &VectorcraftApp) -> bool { diff --git a/crates/ui-egui/src/canvas/drag.rs b/crates/ui-egui/src/canvas/drag.rs index 1980661b8..63b4ef9c9 100644 --- a/crates/ui-egui/src/canvas/drag.rs +++ b/crates/ui-egui/src/canvas/drag.rs @@ -363,6 +363,10 @@ fn end_art_move(app: &mut VectorcraftApp, ctx: &egui::Context) { /// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches). pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) { + // A press lent to a selection tool (Cmd/Ctrl held) does not use the tool of a held key. + if ev.kind == PointerKind::Down && app.session.lent_tool_id(&ev.mods).is_none() { + crate::shortcuts::note_tool_use(app); + } let r = app.session.pointer(ev, view); // The artboard the Artboard tool makes active is the active one (the navigator's, the // Artboards panel's). diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index e0bd9f029..49baf494b 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -290,6 +290,11 @@ fn begin_drag(app: &mut VectorcraftApp, ui: &Ui, f: &Pointing, p: Pos2) { crate::document_drag::begin(app, ui.ctx(), ev.pos); Drag::Tool }; + // A drag of the Zoom, Hand or Rotate View tool uses the tool of a held shortcut; held Space's + // view tools are not that tool. + if !space && matches!(d, Drag::ZoomBox { .. } | Drag::ZoomScrub { .. } | Drag::Pan { .. } | Drag::RotateView { .. }) { + crate::shortcuts::note_tool_use(app); + } // Panning, zooming or turning the view takes the pointer from the tool's hover. if !tool_holds(d) { app.session.tool_hover_ended(view); diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index b52c4087a..d9fa7f09c 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -1602,13 +1602,18 @@ fn shapes(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { } /// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the -/// GPU takes in one buffer. +/// GPU takes in one buffer, without the art's raster: the render worker's frame can land in +/// either frame of a comparison. fn frame_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { frame(app, ctx, vec![]); let raw = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; let mut out = ctx.run_ui(raw, |ui| show(app, ui)); out.textures_delta.clear(); - let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 }; + let art = app.canvas.texture.as_ref().map(|t| t.id()); + let mesh = |p: &egui::ClippedPrimitive| match &p.primitive { + egui::epaint::Primitive::Mesh(m) if Some(m.texture_id) != art => m.vertices.len(), + _ => 0, + }; ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum() } @@ -6159,6 +6164,32 @@ fn clear_history_waits_while_the_ime_composes() { assert!(crate::menus::enabled(&app, "edit.clearHistory")); } +/// New Document from State waits while an IME composes, as Undo does: no document is made and the +/// composition goes on. +#[test] +fn new_document_from_state_waits_while_the_ime_composes() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + typing_frame(&mut app, &ctx, vec![]); + app.select_tool("type"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let at = xf.to_screen(Point::new(100.0, 100.0)); + let click = + |p: Pos2, pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(at), click(at, true)]); + typing_frame(&mut app, &ctx, vec![click(at, false)]); + typing_frame(&mut app, &ctx, vec![preedit("が", 1)]); + assert!(app.session.tool_composing()); + assert!(!crate::menus::enabled(&app, "edit.newDocumentFromState")); + crate::menus::invoke(&mut app, "edit.newDocumentFromState", json!({})); + assert!(app.session.tool_composing(), "the composition goes on"); + assert_eq!(app.session.documents().len(), 1); + typing_frame(&mut app, &ctx, vec![preedit("", 0), egui::Event::Ime(egui::ImeEvent::Commit("雅".into()))]); + assert!(crate::menus::enabled(&app, "edit.newDocumentFromState")); +} + #[test] fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -6345,7 +6376,7 @@ fn editing_keys_after_an_ime_commit_apply_in_the_same_frame() { } /// Cmd/Ctrl+Enter bound to a command runs the command and types no line break, also when a plain -/// Enter comes in the same frame. Unbound, it types a line break as Enter does. +/// Enter comes in the same frame. Unbound, it ends editing and keeps the text. #[test] fn a_command_on_cmd_enter_runs_without_typing_a_line_break() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); @@ -6360,18 +6391,83 @@ fn a_command_on_cmd_enter_runs_without_typing_a_line_break() { modifiers: egui::Modifiers::COMMAND, }); typing_frame(&mut app, &ctx, vec![egui::Event::Text("a".into())]); - typing_frame(&mut app, &ctx, cmd_enter.to_vec()); - assert_eq!(plain(&app, id), "a\n", "nothing is bound to Cmd+Enter"); crate::keymap::set_override(&mut app.ui.shortcut_overrides, "view.grid", "Cmd+Enter"); crate::shortcut_editor::sync(&app.ui); let grid = app.ui.view.grid; typing_frame(&mut app, &ctx, cmd_enter.to_vec()); - let ran = (plain(&app, id), app.ui.view.grid); + let ran = (plain(&app, id), app.ui.view.grid, app.session.tool_wants_text()); typing_frame(&mut app, &ctx, [tap(egui::Key::Enter).to_vec(), cmd_enter.to_vec()].concat()); app.ui.shortcut_overrides.clear(); crate::shortcut_editor::sync(&app.ui); - assert_eq!((ran.0.as_str(), ran.1), ("a\n", !grid), "Cmd+Enter runs Show Grid only"); - assert_eq!((plain(&app, id).as_str(), app.ui.view.grid), ("a\n\n", grid), "Enter types a line break and Cmd+Enter runs Show Grid"); + assert_eq!((ran.0.as_str(), ran.1, ran.2), ("a", !grid, true), "Cmd+Enter runs Show Grid only"); + assert_eq!((plain(&app, id).as_str(), app.ui.view.grid), ("a\n", grid), "Enter types a line break and Cmd+Enter runs Show Grid"); + assert!(app.session.tool_wants_text(), "a bound Cmd+Enter does not end editing"); + typing_frame(&mut app, &ctx, cmd_enter.to_vec()); + assert_eq!(plain(&app, id), "a\n", "nothing is bound to Cmd+Enter: no line break"); + assert!(!app.session.tool_wants_text(), "unbound, Cmd+Enter ends editing"); + assert_eq!(app.session.tool_id(), "selection"); +} + +/// Cmd+Enter (Ctrl+Enter on Windows and Linux) ends editing like Escape: a repeat of it, and text, +/// keys and a paste after it in the same frame, leave the type as it was. Enter, Shift+Enter and +/// Ctrl+Enter on macOS still type line breaks. +#[test] +fn cmd_enter_ends_text_editing_and_keeps_the_text() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + let ctx = egui::Context::default(); + let id = start_typing(&mut app, &ctx); + let key = |pressed, repeat, modifiers| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat, modifiers }; + let mac_ctrl = egui::Modifiers { ctrl: true, ..Default::default() }; + typing_frame(&mut app, &ctx, vec![egui::Event::Text("a".into())]); + typing_frame(&mut app, &ctx, vec![key(true, false, egui::Modifiers::NONE), key(false, false, egui::Modifiers::NONE)]); + typing_frame(&mut app, &ctx, vec![key(true, false, egui::Modifiers::SHIFT), key(false, false, egui::Modifiers::SHIFT)]); + typing_frame(&mut app, &ctx, vec![key(true, false, mac_ctrl), key(false, false, mac_ctrl)]); + assert_eq!(plain(&app, id), "a\n\u{2028}\n"); + assert!(app.session.tool_wants_text()); + let held = vec![ + key(true, false, egui::Modifiers::COMMAND), + key(true, true, egui::Modifiers::COMMAND), + egui::Event::Text("b".into()), + egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + egui::Event::Paste("c".into()), + egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }, + ]; + typing_frame(&mut app, &ctx, held); + assert!(!app.session.tool_wants_text(), "editing ended"); + assert_eq!(app.session.tool_id(), "selection"); + assert_eq!(plain(&app, id), "a\n\u{2028}\n", "the text is kept, and nothing after the chord changes it"); + assert_eq!(app.session.active().unwrap().selection.objects, vec![vectorcraft_doc::NodeId(id)], "the type stays selected"); + typing_frame(&mut app, &ctx, vec![key(true, true, egui::Modifiers::COMMAND)]); + typing_frame(&mut app, &ctx, vec![key(false, false, egui::Modifiers::COMMAND)]); + assert!(app.ui.dialog.is_none(), "the repeats of the chord open no dialog"); + assert_eq!(plain(&app, id), "a\n\u{2028}\n"); +} + +/// While the IME composes, Cmd+Enter is the IME's: it does not end editing, drop the marked text or +/// type a line break, in the frame that starts the composition or in a later one. +#[test] +fn cmd_enter_while_composing_keeps_the_composition_and_editing() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.prefs.placeholder_text = false; + let ctx = egui::Context::default(); + let id = start_typing(&mut app, &ctx); + let cmd_enter = [true, false].map(|pressed| egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::COMMAND, + }); + typing_frame(&mut app, &ctx, vec![egui::Event::Text("a".into())]); + typing_frame(&mut app, &ctx, [vec![preedit("か", 1)], cmd_enter.to_vec()].concat()); + assert!(app.session.tool_composing(), "the same frame keeps the composition"); + assert!(app.session.tool_wants_text(), "the same frame keeps editing"); + assert_eq!(plain(&app, id), "aか", "the same frame keeps the marked text and types no line break"); + typing_frame(&mut app, &ctx, cmd_enter.to_vec()); + assert!(app.session.tool_composing(), "a later frame keeps the composition"); + assert!(app.session.tool_wants_text(), "a later frame keeps editing"); + assert_eq!(plain(&app, id), "aか", "a later frame keeps the marked text and types no line break"); } /// Reset to a blank document, select the Type tool and click document point (100, 100), returning @@ -9016,6 +9112,205 @@ fn hidden_artboards_leave_a_paper_canvas_without_edges() { assert_eq!(look(&mut app), (false, true)); } +/// One headless canvas frame → whether the fade outside the artboards is drawn. +fn fades(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + fade_mesh(&shapes(app, ctx)).is_some() +} + +/// The fade outside the artboards among `shapes`, with its position: the mesh in [`FADE_OUTSIDE`]. +fn fade_mesh(shapes: &[Shape]) -> Option<(usize, &egui::Mesh)> { + shapes.iter().enumerate().find_map(|(i, s)| match s { + Shape::Mesh(m) if !m.vertices.is_empty() && m.vertices.iter().all(|v| v.color == FADE_OUTSIDE) => Some((i, &**m)), + _ => None, + }) +} + +/// Does `mesh` cover the screen point `p`? +fn mesh_covers(mesh: &egui::Mesh, p: Pos2) -> bool { + let cross = |a: Pos2, b: Pos2| (b - a).x * (p - a).y - (b - a).y * (p - a).x; + mesh.indices.chunks(3).any(|t| { + let [a, b, c] = [t[0], t[1], t[2]].map(|i| mesh.vertices[i as usize].pos); + let (d0, d1, d2) = (cross(a, b), cross(b, c), cross(c, a)); + (d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0) + }) +} + +/// Artboard Options › Fade Region Outside Artboard: while the Artboard tool is active the area +/// outside the artboards draws darker; with another tool, or with the preference off, it draws as +/// usual. +#[test] +fn the_artboard_tool_fades_the_pasteboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + assert_eq!(FADE_OUTSIDE.a(), 102, "black at 40%: each channel under it shows at 60%"); + assert!(app.session.prefs.fade_region_outside_artboard, "on by default"); + assert!(!fades(&mut app, &ctx), "the Selection tool"); + app.select_tool("artboard"); + assert!(fades(&mut app, &ctx), "the Artboard tool"); + app.select_tool("rectangle"); + assert!(!fades(&mut app, &ctx), "the Rectangle tool"); + app.run("prefs.set", json!({"key": "fadeRegionOutsideArtboard", "value": false})).unwrap(); + app.select_tool("artboard"); + assert!(!fades(&mut app, &ctx), "the preference off"); +} + +/// Presentation Mode shows only the art: with the Artboard tool active and the fade on, it draws no +/// fade, and the fade comes back when Presentation Mode ends. +#[test] +fn presentation_mode_draws_no_fade() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + app.select_tool("artboard"); + assert!(fades(&mut app, &ctx), "the Artboard tool"); + app.run("view.presentation", json!({})).unwrap(); + assert_eq!(app.ui.screen_mode, 3); + assert_eq!(app.session.tool_id(), "artboard"); + assert!(!fades(&mut app, &ctx), "Presentation Mode"); + app.run("view.presentation", json!({})).unwrap(); + assert!(fades(&mut app, &ctx), "after Presentation Mode"); +} + +/// A row of boards aligned at the top, each a different height, gives a number of parts that grows +/// with the number of boards, not with its square, and the parts still add up to the uncovered area. +#[test] +fn outside_rects_of_a_row_of_boards_stay_few() { + let n = 200; + let boards: Vec = + (0..n).map(|i| Rect::new(20.0 * f64::from(i) + 10.0, 10.0, 20.0 * f64::from(i) + 20.0, 20.0 + 10.0 * f64::from(i))).collect(); + let bounds = Rect::new(0.0, 0.0, 20.0 * f64::from(n) + 10.0, 10.0 * f64::from(n) + 40.0); + let parts = outside_rects(bounds, &boards, usize::MAX).unwrap(); + assert!(parts.len() <= 3 * n as usize, "{} parts for {n} boards", parts.len()); + let area = |r: &Rect| r.width() * r.height(); + let covered: f64 = boards.iter().map(area).sum(); + assert_eq!(parts.iter().map(area).sum::(), area(&bounds) - covered); +} + +/// 500 narrow tall boards crossed by 500 narrow wide ones leave about 250,000 gaps between them. +/// The fade then covers only the parts outside the box around all the boards, so it stays a few +/// rectangles. +#[test] +fn the_fade_of_crossing_boards_stays_bounded() { + let n = 500; + let side = 4.0 * f64::from(n); + let boards: Vec = (0..n) + .flat_map(|i| { + let at = 4.0 * f64::from(i); + [Rect::new(at, 0.0, at + 1.0, side), Rect::new(0.0, at, side, at + 1.0)] + }) + .collect(); + let bounds = Rect::new(-100.0, -100.0, side + 100.0, side + 100.0); + assert!(outside_rects(bounds, &boards, FADE_MAX_RECTS).is_none(), "more than {FADE_MAX_RECTS} gaps"); + let parts = fade_rects(bounds, &boards); + assert!(parts.len() <= 4, "{} parts", parts.len()); + let area = |r: &Rect| r.width() * r.height(); + let all = Rect::new(0.0, 0.0, side, side); + assert_eq!(parts.iter().map(area).sum::(), area(&bounds) - area(&all)); +} + +/// The fade covers the art that lies on the pasteboard as well as the pasteboard: it is drawn over +/// the art, over a point of an object outside the artboard and not over a point of an object on it, +/// in an upright view and in a rotated one. +#[test] +fn the_fade_darkens_art_on_the_pasteboard_and_not_on_the_artboard() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 400, "height": 300})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": -150, "y": 100, "width": 100, "height": 100})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap(); + app.canvas.worker_started = true; // render on this thread + let ctx = egui::Context::default(); + shapes(&mut app, &ctx); // the first frame fits the view to the artboard + app.view_mut().unwrap().zoom = 0.5; + app.select_tool("artboard"); + for rotation in [0.0, 30.0] { + app.view_mut().unwrap().rotation = rotation; + let s = shapes(&mut app, &ctx); + let art = app.canvas.texture.as_ref().unwrap().id(); + let image = s.iter().position(|s| matches!(s, Shape::Mesh(m) if m.texture_id == art)).expect("the art"); + let (at, mesh) = fade_mesh(&s).expect("the fade"); + assert!(at > image, "over the art, rotation {rotation}"); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + assert!(mesh_covers(mesh, xf.to_screen(Point::new(-100.0, 150.0))), "the object outside, rotation {rotation}"); + assert!(!mesh_covers(mesh, xf.to_screen(Point::new(200.0, 150.0))), "the object on the artboard, rotation {rotation}"); + assert!(mesh_covers(mesh, xf.to_screen(Point::new(200.0, -40.0))), "the pasteboard, rotation {rotation}"); + } +} + +/// The parts outside the boards: none where boards cover the bounds, the bounds without boards, and +/// otherwise rectangles that do not overlap a board or each other and add up to the uncovered area. +#[test] +fn outside_rects_cover_the_bounds_minus_the_boards() { + let bounds = Rect::new(0.0, 0.0, 100.0, 100.0); + assert!(fade_rects(bounds, &[Rect::new(-10.0, -10.0, 110.0, 110.0)]).is_empty()); + assert_eq!(fade_rects(bounds, &[]), vec![bounds]); + let boards = [Rect::new(10.0, 10.0, 40.0, 40.0), Rect::new(30.0, 30.0, 60.0, 80.0), Rect::new(90.0, -20.0, 130.0, 50.0)]; + let parts = fade_rects(bounds, &boards); + let area = |r: &Rect| r.width() * r.height(); + let overlap = |a: &Rect, b: &Rect| (a.x1.min(b.x1) - a.x0.max(b.x0)).max(0.0) * (a.y1.min(b.y1) - a.y0.max(b.y0)).max(0.0); + for (i, p) in parts.iter().enumerate() { + assert!(boards.iter().all(|b| overlap(p, b) == 0.0), "{p:?} overlaps a board"); + assert!(parts[i + 1..].iter().all(|q| overlap(p, q) == 0.0), "{p:?} overlaps another part"); + } + // Inside the bounds the boards cover 900 + 1,500 less their 100 of overlap, and 500 of the third. + assert_eq!(parts.iter().map(area).sum::(), 10_000.0 - (900.0 + 1_500.0 - 100.0 + 500.0)); +} + +/// Artboard Options › Update While Dragging: on, the pasteboard stays darker while the Artboard tool +/// moves or resizes an artboard; off, it draws as usual during the drag and darker again after it. +#[test] +fn update_while_dragging_keeps_or_drops_the_fade_during_an_artboard_drag() { + for update in [true, false] { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("prefs.set", json!({"key": "artboardUpdateWhileDragging", "value": update})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + let r = app.session.active().unwrap().doc.artboards[0].rect; + let view = app.view_info(); + // A move from the artboard's middle, then a resize from its right edge. + let (cx, cy) = (r.center().x, r.center().y); + for (from, to) in + [(Point::new(cx, cy), Point::new(cx + 40.0, cy + 30.0)), (Point::new(r.x1 + 40.0, cy + 30.0), Point::new(r.x1 + 90.0, cy + 30.0))] + { + dispatch(&mut app, &PointerEvent::new(PointerKind::Down, from.x, from.y), view); + for p in [from + (to - from) * 0.5, to] { + dispatch(&mut app, &PointerEvent::new(PointerKind::Drag, p.x, p.y), view); + } + assert!(app.session.tool_transforming(), "the artboard is being dragged"); + assert_eq!(fades(&mut app, &ctx), update, "Update While Dragging {update}"); + dispatch(&mut app, &PointerEvent::new(PointerKind::Up, to.x, to.y), view); + assert!(!app.session.tool_transforming()); + assert!(fades(&mut app, &ctx), "darker again after the drag"); + } + let moved = app.session.active().unwrap().doc.artboards[0].rect; + assert!(moved.x0 > r.x0 && moved.width() > r.width(), "moved, then resized: {moved:?} from {r:?}"); + } +} + +/// The fade is drawn only on the canvas: exports with the Artboard tool active and the fade on are +/// the same as with the Selection tool and the fade off. +#[test] +fn the_pasteboard_fade_leaves_exports_unchanged() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100})).unwrap(); + app.run("shape.rectangle", json!({"x": -40, "y": 20, "width": 120, "height": 40})).unwrap(); + let exports = |app: &mut VectorcraftApp| -> Vec { + ["svg", "png"].iter().map(|f| app.run("document.export", json!({"format": f})).unwrap()).collect() + }; + app.run("prefs.set", json!({"key": "fadeRegionOutsideArtboard", "value": false})).unwrap(); + let plain = exports(&mut app); + app.run("prefs.set", json!({"key": "fadeRegionOutsideArtboard", "value": true})).unwrap(); + app.select_tool("artboard"); + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![]); + assert!(fades_outside_artboards(&app)); + assert_eq!(exports(&mut app), plain); +} + /// Finished worker renders are uploaded whenever they are not older than what is currently /// displayed on screen. During continuous edit streams (drag previews, held arrow nudges, or /// command bursts) where the document revision advances every frame, intermediate worker frames diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 41d73ffae..ae8fbccc0 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -20,7 +20,7 @@ mod tabs; #[cfg(test)] use control_bar::ISOLATION_BUTTON; -pub use control_bar::{AnchorControls, anchor_buttons, anchor_controls, control_bar}; +pub use control_bar::{AnchorControls, CONTROL_BAR_EDGES, anchor_buttons, anchor_controls, control_bar}; #[cfg(test)] use hints::hint_segments; pub use hints::{hint_bar, hint_strings}; diff --git a/crates/ui-egui/src/chrome/control_bar.rs b/crates/ui-egui/src/chrome/control_bar.rs index 70c6fe272..5374305e0 100644 --- a/crates/ui-egui/src/chrome/control_bar.rs +++ b/crates/ui-egui/src/chrome/control_bar.rs @@ -261,19 +261,83 @@ fn groups_before_transform(app: &mut VectorcraftApp, ui: &mut Ui, objects: usize true } -/// The Control bar (Window → Control), context-sensitive like Illustrator's. +/// The edges of the window the Control bar docks to (`window.control.dock {edge}`): the top, then +/// the bottom. +pub const CONTROL_BAR_EDGES: [&str; 2] = ["top", "bottom"]; + +/// The side of the Control bar's panel menu button, at the bar's right end. +const MENU_BUTTON: f32 = 16.0; + +/// The height of the band drawn along the edge of the window that a gripper drag docks the bar to. +const DOCK_BAND: f32 = 4.0; + +/// Docks the Control bar to `edge` of [`CONTROL_BAR_EDGES`], through its command. +fn dock(app: &mut VectorcraftApp, edge: &str) { + crate::menus::invoke(app, "window.control.dock", json!({ "edge": edge })); +} + +/// The Control bar's panel menu: Dock to Top and Dock to Bottom, the edge the bar is docked to +/// checked. +fn panel_menu(app: &mut VectorcraftApp, ui: &mut Ui, rect: egui::Rect) { + let resp = crate::panels::menu_button(ui, "control", rect); + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(160.0); + for (i, label) in [tl!("Dock to Top"), tl!("Dock to Bottom")].into_iter().enumerate() { + if widgets::menu_item(ui, label, true, usize::from(app.ui.control_bar_bottom) == i) { + dock(app, CONTROL_BAR_EDGES[i]); + } + } + }); +} + +/// The Control bar's gripper. Dragging it shows a band along the top or the bottom edge of the +/// window, whichever the pointer is nearer, and docks the bar there on release. +fn gripper(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens) { + let (g, resp) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::drag()); + for i in 0..5 { + ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); + } + let resp = resp.on_hover_cursor(egui::CursorIcon::Grab); + if !resp.dragged() && !resp.drag_stopped() { + return; + } + let Some(pos) = resp.interact_pointer_pos().or_else(|| ui.ctx().pointer_latest_pos()) else { return }; + let window = ui.ctx().content_rect(); + let bottom = pos.y > window.center().y; + if resp.drag_stopped() { + if bottom != app.ui.control_bar_bottom { + dock(app, CONTROL_BAR_EDGES[usize::from(bottom)]); + } + return; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let band = if bottom { + egui::Rect::from_min_max(egui::pos2(window.left(), window.bottom() - DOCK_BAND), window.max) + } else { + egui::Rect::from_min_max(window.min, egui::pos2(window.right(), window.top() + DOCK_BAND)) + }; + let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("control_bar_dock_band")); + ui.ctx().layer_painter(layer).rect_filled(band, 0.0, t.accent); +} + +/// The Control bar (Window → Control), context-sensitive like Illustrator's, docked to the top or +/// the bottom of the window. pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - egui::Panel::top("control_bar") + let bottom = app.ui.control_bar_bottom; + let panel = if bottom { egui::Panel::bottom("control_bar") } else { egui::Panel::top("control_bar") }; + let bar = panel .exact_size(34.0) .frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))) .show(ui, |ui| { - ui.horizontal_centered(|ui| { - // Gripper - let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover()); - for i in 0..5 { - ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled); - } + // The panel menu button takes the right end, and the controls trim to the room left of it: + // they are laid out in that room, so every room check below leaves the button's width. + let full = ui.max_rect(); + let menu = egui::Rect::from_center_size(egui::pos2(full.right() - MENU_BUTTON / 2.0, full.center().y), vec2(MENU_BUTTON, MENU_BUTTON)); + let room = egui::Rect::from_min_max(full.min, egui::pos2(menu.left() - ui.spacing().item_spacing.x, full.bottom())); + let layout = egui::Layout::left_to_right(egui::Align::Center); + let controls = ui.scope_builder(egui::UiBuilder::new().max_rect(room).layout(layout), |ui| { + gripper(app, ui, &t); if app.session.active().is_none() { ui.label(egui::RichText::new(tl!("No Document")).color(t.text_dim)); return; @@ -405,5 +469,14 @@ pub fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } }); + // Controls that keep their size can still run into the button's room in a narrow window; + // the button is left out then. + if controls.response.rect.right() <= menu.left() { + panel_menu(app, ui, menu); + } }); + // Docked at the bottom, the bar is a bottom bar that menus stop above. + if bottom { + widgets::set_menu_floor(ui.ctx(), bar.response.rect.top()); + } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index 86a85699d..00d790964 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -1801,6 +1801,139 @@ fn full_screen_modes_fill_the_screen_and_normal_mode_restores_the_window() { assert!(sent(&mut app, true).is_empty(), "still full screen, as the user had it"); } +/// The window the Control bar docking tests draw the bar in. +const DOCK_SCREEN: egui::Rect = egui::Rect { min: egui::Pos2::ZERO, max: egui::pos2(1200.0, 300.0) }; + +/// One headless frame of the Control bar alone; returns its accessibility nodes. +fn control_bar_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec) -> Vec { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(DOCK_SCREEN), ..Default::default() }, |ui| super::control_bar(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() +} + +/// The center of the bounds of the node whose label, without a check mark, is `name`. +fn node_center(nodes: &[egui::accesskit::Node], name: &str) -> Option { + let n = nodes.iter().find(|n| n.label().is_some_and(|l| l.trim_start_matches('✓').trim() == name))?; + let b = n.bounds()?; + Some(egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)) +} + +/// A primary-button press or release at `pos`. +fn press_at(pos: egui::Pos2, pressed: bool) -> egui::Event { + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() } +} + +/// The Control bar's panel menu has Dock to Top and Dock to Bottom, the edge in use checked, and +/// each docks the bar to that edge of the window. +#[test] +fn the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + crate::theme::install_fonts(&ctx); + let pick = |app: &mut crate::VectorcraftApp, item: &str| { + control_bar_frame(app, &ctx, vec![]); + let menu = node_center(&control_bar_frame(app, &ctx, vec![]), "Panel menu").unwrap_or_else(|| panic!("the bar has a panel menu")); + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(menu), press_at(menu, true)]); + control_bar_frame(app, &ctx, vec![press_at(menu, false)]); + let nodes = control_bar_frame(app, &ctx, vec![]); + let checked: Vec = nodes.iter().filter_map(|n| n.label()).filter(|l| l.starts_with('✓')).map(str::to_string).collect(); + let at = node_center(&nodes, item).unwrap_or_else(|| panic!("the menu lists {item}")); + // Below the button at the top, and above it at the bottom: the menu opens inside the window. + assert!(DOCK_SCREEN.contains(at) && (at.y < menu.y) == app.ui.control_bar_bottom, "{item} at {at:?}, the button at {menu:?}"); + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(at), press_at(at, true)]); + control_bar_frame(app, &ctx, vec![press_at(at, false)]); + control_bar_frame(app, &ctx, vec![]); + let menu = node_center(&control_bar_frame(app, &ctx, vec![]), "Panel menu").unwrap_or_else(|| panic!("the bar still shows")); + (checked, menu.y) + }; + let (checked, y) = pick(&mut app, "Dock to Bottom"); + assert_eq!(checked, ["✓ Dock to Top"], "the bar starts docked at the top"); + assert!(app.ui.control_bar_bottom, "Dock to Bottom docks it at the bottom"); + assert!(y > DOCK_SCREEN.bottom() - 34.0, "the bar is drawn along the bottom of the window: {y}"); + let (checked, y) = pick(&mut app, "Dock to Top"); + assert_eq!(checked, ["✓ Dock to Bottom"]); + assert!(!app.ui.control_bar_bottom && y < 34.0, "Dock to Top docks it at the top again: {y}"); +} + +/// Dragging the Control bar's gripper docks the bar at the edge of the window the pointer is +/// released nearer; a drag released nearer the edge it is at leaves it there. +#[test] +fn dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // The gripper is the bar's first item, inside its 10-point margin, centered in the 34-point bar. + let grip = |bottom: bool| egui::pos2(13.0, if bottom { DOCK_SCREEN.bottom() - 17.0 } else { 17.0 }); + let drag = |app: &mut crate::VectorcraftApp, from: egui::Pos2, to: egui::Pos2| { + control_bar_frame(app, &ctx, vec![]); + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(from), press_at(from, true)]); + for k in 1..=4 { + control_bar_frame(app, &ctx, vec![egui::Event::PointerMoved(from.lerp(to, k as f32 / 4.0))]); + } + control_bar_frame(app, &ctx, vec![press_at(to, false)]); + }; + drag(&mut app, grip(false), egui::pos2(400.0, 200.0)); + assert!(app.ui.control_bar_bottom, "released nearer the bottom: docked at the bottom"); + drag(&mut app, grip(true), egui::pos2(400.0, 160.0)); + assert!(app.ui.control_bar_bottom, "released nearer the bottom again: still at the bottom"); + drag(&mut app, grip(true), egui::pos2(400.0, 120.0)); + assert!(!app.ui.control_bar_bottom, "released nearer the top: docked at the top"); + drag(&mut app, grip(false), egui::pos2(400.0, 140.0)); + assert!(!app.ui.control_bar_bottom, "released nearer the top again: still at the top"); +} + +/// `window.control.dock {edge}` docks the Control bar to the top or the bottom of the window, and +/// the panel menu checks the edge in use; another edge, or none, is an error and changes nothing. +#[test] +fn the_control_bar_dock_command_takes_top_or_bottom() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + assert_eq!(app.run("window.control.dock", json!({"edge": "bottom"})).unwrap(), json!({"edge": "bottom"})); + assert!(app.ui.control_bar_bottom); + assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "bottom"})), Some(true)); + assert_eq!(crate::menus::checked(&app, "window.control.dock", &json!({"edge": "top"})), Some(false)); + for p in [json!({"edge": "left"}), json!({"edge": "Bottom"}), json!({"edge": 1}), json!({})] { + let err = app.run("window.control.dock", p.clone()).unwrap_err(); + assert_eq!(err, "`edge` must be one of top, bottom", "{p}"); + assert!(app.ui.control_bar_bottom, "{p} leaves the bar at the bottom"); + } + assert_eq!(app.run("window.control.dock", json!({"edge": "top"})).unwrap(), json!({"edge": "top"})); + assert!(!app.ui.control_bar_bottom); +} + +/// Menus stop above a Control bar docked at the bottom when the status bar is hidden, above the +/// status and hint bars when they show, and at the bottom of the window when the bar is docked at +/// the top or a screen mode hides it. +#[test] +fn menus_stop_above_a_control_bar_docked_at_the_bottom() { + let mut app = crate::VectorcraftApp::new(Session::new(), Default::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let floor = |app: &mut crate::VectorcraftApp, status: bool, bottom: bool, screen_mode: u8| { + (app.ui.status_bar, app.ui.control_bar, app.ui.control_bar_bottom, app.ui.screen_mode) = (status, true, bottom, screen_mode); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + crate::widgets::menu_floor(&ctx) + }; + let bar_top = screen.bottom() - 34.0; + let f = floor(&mut app, false, true, 0); + assert!((f - bar_top).abs() < 1.0, "status bar hidden: menus stop at the Control bar's top, {f}"); + let f = floor(&mut app, false, true, 2); + assert_eq!(f, screen.bottom(), "screen mode 2 hides the Control bar: menus stop at the window's bottom"); + let f = floor(&mut app, false, true, 0); + assert!((f - bar_top).abs() < 1.0, "shown again: {f}"); + let f = floor(&mut app, false, false, 0); + assert_eq!(f, screen.bottom(), "docked at the top: menus stop at the window's bottom"); + let f = floor(&mut app, true, true, 0); + assert!(f < bar_top - 1.0, "the status and hint bars show above the Control bar: menus stop above them, {f}"); +} + /// Two or more selected objects show all six Distribute Objects buttons in the Control bar; /// a single object keeps the bar as it was. A click distributes as the Align panel's button does. #[test] @@ -1955,6 +2088,7 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { ctx.enable_accesskit(); crate::theme::install_fonts(&ctx); let mut seen = false; + let mut menu_seen = false; // The Align group, then for two objects the Distribute group (#5372): each by its controls. let mut groups: Vec<(&str, Vec<&str>)> = vec![( "Align", @@ -2002,16 +2136,22 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { let fields = ["X:", "Y:", "W:", "H:"].map(right); assert!(fields.iter().all(Option::is_some) || fields.iter().all(Option::is_none), "{count} objects, {width}: {fields:?}"); assert!(nodes.iter().any(|(_, n)| n.label() == Some("Transform")), "{count} objects, {width}: the Transform link shows"); - // The bar's content ends at its 10-point inner margin. + // The panel menu button takes the bar's right end, and the bar's content ends before it + // (without the button, at the bar's 10-point inner margin). Once a bar shows the button, + // a wider one keeps it: the fields and groups trim to the room left of it. + let menu = nodes.iter().find(|(_, n)| n.label() == Some("Panel menu")).and_then(|(_, n)| n.bounds()); + assert!(!menu_seen || menu.is_some(), "{count} objects, {width}: the panel menu button left out after a narrower bar showed it"); + menu_seen |= menu.is_some(); + let edge = menu.map_or(f64::from(width) - 10.0, |m| m.x0); if let Some(h) = fields[3] { - assert!(h <= f64::from(width) - 10.0, "{count} objects, {width}: H ends at {h}"); + assert!(h <= edge, "{count} objects, {width}: H ends at {h}, the content's room at {edge}"); } assert!(!seen || fields[3].is_some(), "{count} objects, {width}: left out after a narrower bar showed them"); seen |= fields[3].is_some(); // The Align and Distribute buttons show whole inside the margin, with the Transform // link after them, or are left out; once shown, a wider bar keeps them. let end = |name: &str| nodes.iter().find(|(_, n)| n.label() == Some(name)).and_then(|(_, n)| n.bounds()).map(|b| b.x1); - let inside = |x: f64| x <= f64::from(width) - 10.0; + let inside = |x: f64| x <= edge; for ((group, names), was) in groups.iter().zip(groups_seen.iter_mut()) { let ends: Vec> = names.iter().map(|n| end(n)).collect(); let shown = ends.iter().all(Option::is_some); @@ -2026,6 +2166,7 @@ fn the_control_bar_shows_the_inline_transform_fields_only_when_they_fit() { } } assert!(seen, "{count} objects: a wide bar has room for the fields"); + assert!(menu_seen, "{count} objects: a wide bar has room for the panel menu button"); assert!(groups_seen.iter().all(|s| *s), "{count} objects: a wide bar shows every group: {groups_seen:?}"); } } diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs index fa2be21c0..adbf1223f 100644 --- a/crates/ui-egui/src/custom_toolbars.rs +++ b/crates/ui-egui/src/custom_toolbars.rs @@ -343,7 +343,10 @@ pub(crate) fn show_dialog(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // As wide as the field and as tall as the buttons, as in `dialogs::show`: a right-to-left + // layout would take all the height left in the window and center the buttons in it. + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::primary_button(ui, tl!("OK")).clicked() { ok = true; } @@ -445,7 +448,8 @@ pub(crate) fn show_manage(app: &mut VectorcraftApp, ctx: &egui::Context) { } }); ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::primary_button(ui, tl!("OK")).clicked() { ok = true; } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c98a23887..1e301a7ca 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -92,8 +92,9 @@ pub mod raster_effects; pub mod rearrange_artboards; pub mod recolor; mod recovery; +pub mod relink; pub mod rename_artboards; -mod revolve; +pub(crate) mod revolve; pub(crate) mod revolve_gizmo; pub mod saturate; pub mod save_brush_library; @@ -368,6 +369,7 @@ registry! { OfficeExport: [office_export::KIND] => office_export::SPEC, DxfOptions: [dxf_options::KIND] => dxf_options::SPEC, PlacementOptions: [placement_options::KIND] => placement_options::SPEC, + Relink: [relink::KIND] => relink::SPEC, Package: [package::KIND] => package::SPEC, ActionBatch: [action_batch::KIND] => action_batch::SPEC, ActionSetOptions: [action_set_options::KIND] => action_set_options::SPEC, diff --git a/crates/ui-egui/src/dialogs/artboard_options.rs b/crates/ui-egui/src/dialogs/artboard_options.rs index 94ec54317..0ce0de935 100644 --- a/crates/ui-egui/src/dialogs/artboard_options.rs +++ b/crates/ui-egui/src/dialogs/artboard_options.rs @@ -7,6 +7,9 @@ //! and its video display options, Show Video Safe Areas and Video Ruler Pixel Aspect Ratio //! (`showVideoSafeAreas`, `videoRulerPixelAspectRatio`) //! (https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html). +//! Under Global, Fade Region Outside Artboard and Update While Dragging set the application +//! preferences `fadeRegionOutsideArtboard` and `artboardUpdateWhileDragging` for every document +//! (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, Artboard options). //! Sizing choices also go to the other selected artboards; fitting changes only the active //! artboard around stationary artwork. @@ -28,8 +31,8 @@ pub(super) const SPEC: DialogSpec = DialogSpec { body, confirm, // A new artboard's dialog opens without these; its swatch, checkboxes and Video Ruler Pixel - // Aspect Ratio field add them. - inputs: |_| &["color", "showCenterMark", "showCrossHairs", "showVideoSafeAreas", PIXEL_ASPECT], + // Aspect Ratio field add them. The Global checkboxes' fields come from the preferences. + inputs: |_| &["color", "showCenterMark", "showCrossHairs", "showVideoSafeAreas", PIXEL_ASPECT, FADE, UPDATE_WHILE_DRAGGING], ..DialogSpec::FORM }; @@ -47,7 +50,8 @@ fn placed(d: &Dialog) -> Rect { /// reference point where it was, as their sizes do. /// Then Background Color: the Fill list (None, White, Black; Custom for another color) and a swatch /// that picks a color of its own, the Show Center Mark, Show Cross Hairs and Show Video Safe Areas -/// checkboxes, and the Video Ruler Pixel Aspect Ratio field. +/// checkboxes, and the Video Ruler Pixel Aspect Ratio field. Last, under Global, the Fade Region +/// Outside Artboard and Update While Dragging checkboxes. fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { let (unit, origin, refi) = (app.session.general_unit(), app.ruler_origin(), reference_index(ui)); let (was, w0, h0) = (placed(d), d.f64("width", 0.0), d.f64("height", 0.0)); @@ -126,9 +130,59 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { } }); }); + ui.add_space(8.0); + ui.label(egui::RichText::new(tl!("Global")).strong()); + form::check(ui, d, FADE, tl!("Fade Region Outside Artboard")); + form::check(ui, d, UPDATE_WHILE_DRAGGING, tl!("Update While Dragging")); false } +/// The preference and field of Fade Region Outside Artboard: while the Artboard tool is active, +/// the canvas draws everything outside the artboards darker. +const FADE: &str = "fadeRegionOutsideArtboard"; +/// The preference and field of Update While Dragging: the area outside the artboards stays darker +/// while the Artboard tool moves or resizes an artboard. +const UPDATE_WHILE_DRAGGING: &str = "artboardUpdateWhileDragging"; + +/// `fields` with the Global checkboxes ([`global_fields`]) added. +fn with_globals(app: &VectorcraftApp, mut fields: Value) -> Value { + for (key, on) in global_fields(app) { + fields[key] = json!(on); + } + fields +} + +/// The Global checkboxes as the preferences have them, for the dialog's fields. +fn global_fields(app: &VectorcraftApp) -> [(&'static str, bool); 2] { + let prefs = &app.session.prefs; + [(FADE, prefs.fade_region_outside_artboard), (UPDATE_WHILE_DRAGGING, prefs.artboard_update_while_dragging)] +} + +/// The Global checkboxes' preferences that the form changed. A value that is not true or false is +/// an error, which keeps the dialog open before anything changes. +fn global_changes(app: &VectorcraftApp, d: &Dialog) -> Result, String> { + let mut values = serde_json::Map::new(); + for (key, now) in global_fields(app) { + match d.fields.get(key) { + Some(Value::Bool(on)) if *on != now => { + values.insert(key.into(), json!(on)); + } + Some(Value::Bool(_)) | None => {} + Some(_) => return Err(format!("`{key}` must be true or false")), + } + } + Ok(values) +} + +/// OK sets the Global checkboxes' preferences that the form changed ([`global_changes`]), in one +/// `prefs.set`, and leaves the preferences alone when none changed. +fn set_globals(app: &mut VectorcraftApp, values: serde_json::Map) -> Result<(), String> { + if values.is_empty() { + return Ok(()); + } + app.run("prefs.set", json!({ "values": values })).map(drop) +} + /// The field of the Video Ruler Pixel Aspect Ratio, as `artboard.setProps` takes it. pub(super) const PIXEL_ASPECT: &str = "videoRulerPixelAspectRatio"; @@ -243,7 +297,7 @@ pub fn open(app: &mut VectorcraftApp) -> Result { "showCenterMark": a.show_center_mark, "showCrossHairs": a.show_cross_hairs, "showVideoSafeAreas": a.show_video_safe_areas, PIXEL_ASPECT: a.video_ruler_pixel_aspect_ratio, }); - app.ui.dialog = Some(Dialog::new(KIND, fields)); + app.ui.dialog = Some(Dialog::new(KIND, with_globals(app, fields))); Ok(json!({ "dialog": KIND })) } @@ -252,10 +306,14 @@ pub fn open(app: &mut VectorcraftApp) -> Result { /// one is among them), each resized about the reference /// point the form's last Width or Height change used (fields `__refx` and `__refy`, its column and /// row; the top-left corner when the form set none), all in one undo step. Fitting applies only -/// to this artboard and never moves its artwork. +/// to this artboard and never moves its artwork. The Global checkboxes' preferences are set only +/// after the artboard command succeeds, so an error leaves them as they were. fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let globals = global_changes(app, d)?; if d.bool("new") { - return confirm_new(app, d); + let r = confirm_new(app, d)?; + set_globals(app, globals)?; + return Ok(r); } let mut p = Value::Object(d.fields.clone()); for k in ["x", "y", "width", "height", PIXEL_ASPECT] { @@ -264,7 +322,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { } } if let Some(o) = p.as_object_mut() { - o.retain(|k, _| !k.starts_with("__")); + o.retain(|k, _| !k.starts_with("__") && k != FADE && k != UPDATE_WHILE_DRAGGING); } let index = p["index"].as_u64().and_then(|i| usize::try_from(i).ok()).unwrap_or(0); let was = app.session.active().and_then(|st| st.doc.artboards.get(index)).map(|a| a.rect); @@ -295,7 +353,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { "moveArt": !d.bool("__fit") && crate::panels::artboards::move_art(app), }); let p = crate::panels::artboards::set_props_params(common, entries).ok_or("no artboard")?; - run_and_close(app, "artboard.setProps", p) + let r = run_and_close(app, "artboard.setProps", p)?; + set_globals(app, globals)?; + Ok(r) } /// `ui.newArtboard` (Alt-click on the Artboards panel's New Artboard): Artboard Options for a new @@ -309,7 +369,7 @@ pub fn open_new(app: &mut VectorcraftApp) -> Result { "new": true, "name": format!("Artboard {}", abs.len() + 1), "x": last.x1 + 20.0, "y": last.y0, "width": last.width(), "height": last.height(), }); - app.ui.dialog = Some(Dialog::new(KIND, fields)); + app.ui.dialog = Some(Dialog::new(KIND, with_globals(app, fields))); Ok(json!({ "dialog": KIND })) } diff --git a/crates/ui-egui/src/dialogs/objects_on_path_options.rs b/crates/ui-egui/src/dialogs/objects_on_path_options.rs index 06aae7f8e..8a02cd37c 100644 --- a/crates/ui-egui/src/dialogs/objects_on_path_options.rs +++ b/crates/ui-egui/src/dialogs/objects_on_path_options.rs @@ -48,7 +48,7 @@ fn reset(_: &VectorcraftApp, d: &mut Dialog) { fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { widgets::label_row(ui, tl!("Pivot:"), LABEL_W, |ui| { let current = d.f64("pivot", f64::from(CENTER)).clamp(0.0, 8.0) as usize; - if let Some(i) = widgets::reference_point(ui, current) { + if let Some(i) = widgets::reference_point_named(ui, current, tl!("Pivot:")) { d.fields.insert("pivot".into(), json!(i)); } }); diff --git a/crates/ui-egui/src/dialogs/placement_options.rs b/crates/ui-egui/src/dialogs/placement_options.rs index 55849558f..dbe8ff827 100644 --- a/crates/ui-egui/src/dialogs/placement_options.rs +++ b/crates/ui-egui/src/dialogs/placement_options.rs @@ -55,7 +55,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_enabled_ui(free, |ui| { widgets::label_row(ui, tl!("Alignment:"), LABEL, |ui| { let current = ALIGN.iter().position(|a| *a == d.str("align")).unwrap_or(4); - if let Some(i) = widgets::reference_point(ui, current) + if let Some(i) = widgets::reference_point_named(ui, current, tl!("Alignment:")) && let Some(a) = ALIGN.get(i) { d.fields.insert("align".into(), json!(a)); diff --git a/crates/ui-egui/src/dialogs/print.rs b/crates/ui-egui/src/dialogs/print.rs index 6b69c9b3f..523c42d06 100644 --- a/crates/ui-egui/src/dialogs/print.rs +++ b/crates/ui-egui/src/dialogs/print.rs @@ -628,7 +628,7 @@ fn general(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_enabled_ui(!tiles && !placed, |ui| { let origin = choice::(d, "placement.origin").unwrap_or_default(); let current = Origin::ALL.iter().position(|o| *o == origin).unwrap_or(4); - if let Some(o) = widgets::reference_point(ui, current).and_then(|i| Origin::ALL.get(i)) { + if let Some(o) = widgets::reference_point_named(ui, current, tl!("Placement:")).and_then(|i| Origin::ALL.get(i)) { set(d, "placement.origin", json!(o.id())); } ui.label("X:"); diff --git a/crates/ui-egui/src/dialogs/relink.rs b/crates/ui-egui/src/dialogs/relink.rs new file mode 100644 index 000000000..ceca12c04 --- /dev/null +++ b/crates/ui-egui/src/dialogs/relink.rs @@ -0,0 +1,316 @@ +//! Links panel → Relink…: the selected links, and the Replace choice of which links the new file +//! takes. Selected Instances relinks the selected images and placed documents, All Instances every +//! object linked to the same files, and Missing Links from This Folder the selected ones and then +//! every other missing link whose file name is in the new file's folder. Each Link Separately asks +//! for a file for each link in turn, one OK each, and relinks them all at the last one. Every +//! choice is one `links.relink` call: one undo step. +//! +//! Fields: `ids` (the images and placed documents), `links` (`[{name, ids}]`, one row per linked +//! file, as `links.check` lists them, then one per embedded image), `scope` (each|selected|all|missingInFolder; default +//! selected), `path` (the file; picked when empty) and `paths` (with each: the files chosen so +//! far, one per row of `links` in order; the next OK takes `path` or picks one for the next row). + +use serde_json::{Value, json}; + +use super::DialogSpec; +use super::form; +use crate::state::Dialog; +use crate::theme::{self, Tokens}; +use crate::{VectorcraftApp, widgets}; + +pub(super) const KIND: &str = "relink"; +const CMD: &str = "links.relink"; +/// (value, label) of each Replace choice. +const SCOPES: [(&str, &str); 4] = [ + ("each", "Each Link Separately"), + ("selected", "Selected Instances"), + ("all", "All Instances"), + ("missingInFolder", "Missing Links from This Folder"), +]; +const LABEL: f32 = 70.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { + heading: |_| tl!("Relink").into(), + body, + confirm, + ok: Some("Relink…"), + min_width: 400.0, + max_width: Some(460.0), + ..DialogSpec::FORM +}; + +/// Open the dialog on the images and placed documents `ids` (default: the selected ones). +pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { + let ids = match p.get("ids").filter(|v| !v.is_null()) { + Some(v) => v.as_array().cloned().ok_or_else(|| format!("`ids` must be an array of object ids, not {v}"))?, + None => crate::panels::links::selected_ids(app).into_iter().map(Value::from).collect(), + }; + if ids.is_empty() { + return Err("select the images to relink, or pass ids".into()); + } + let check = app.session.execute("links.check", &json!({ "ids": ids })).map_err(|e| e.to_string())?; + // One row per file: objects showing different content of one file (pages of a PDF) share it. + let mut by_path: Vec<(Value, Value, Vec)> = vec![]; + for l in check["links"].as_array().into_iter().flatten() { + let ids = l["ids"].as_array().cloned().unwrap_or_default(); + match by_path.iter_mut().find(|(path, _, _)| *path == l["path"]) { + Some((_, _, all)) => all.extend(ids), + None => by_path.push((l["path"].clone(), l["name"].clone(), ids)), + } + } + let mut links: Vec = by_path.into_iter().map(|(_, name, ids)| json!({ "name": name, "ids": ids })).collect(); + // Embedded images have no link: each gets a row of its own, named as the Links panel lists it. + let linked: Vec<&Value> = links.iter().flat_map(|l| l["ids"].as_array().into_iter().flatten()).collect(); + let mut embedded: Vec = vec![]; + for id in ids.iter().filter(|id| !linked.contains(id)) { + if !embedded.contains(id) { + embedded.push(id.clone()); + } + } + if !embedded.is_empty() { + let list = app.session.execute("links.list", &json!({})).map_err(|e| e.to_string())?; + let rows = list["links"].as_array().cloned().unwrap_or_default(); + for id in embedded { + let name = rows.iter().find(|r| r["id"] == id).map_or(Value::Null, |r| r["name"].clone()); + links.push(json!({ "name": name, "ids": [id] })); + } + } + app.ui.dialog = Some(Dialog::new(KIND, json!({ "ids": ids, "links": links, "scope": "selected", "path": "", "paths": [] }))); + Ok(Value::Null) +} + +fn rows(d: &Dialog, key: &str) -> Vec { + d.fields.get(key).and_then(Value::as_array).cloned().unwrap_or_default() +} + +/// The file the next OK uses: `path`, else one picked (none: cancelled). +fn file(app: &mut VectorcraftApp, d: &Dialog) -> Result { + match d.str("path") { + p if !p.is_empty() => Ok(p), + _ => { + let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; + crate::picks::open(app, &pick).ok_or_else(|| "cancelled".to_string()) + } + } +} + +/// OK: relink with the chosen scope. With each, a file for the next link: the dialog stays open +/// until every link has one, then relinks them all. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let scope = d.str("scope"); + let r = if scope == "each" { + let links = rows(d, "links"); + let mut paths = rows(d, "paths"); + if paths.len() < links.len() { + paths.push(json!(file(app, d)?)); + } + if paths.len() < links.len() { + let mut next = d.clone(); + next.fields.insert("paths".into(), json!(paths)); + next.fields.insert("path".into(), json!("")); + app.ui.dialog = Some(next); + return Ok(json!({ "next": links.get(paths.len()).map_or(Value::Null, |l| l["name"].clone()) })); + } + let entries: Vec = links.iter().zip(&paths).map(|(l, p)| json!({ "ids": l["ids"], "path": p })).collect(); + app.run(CMD, json!({ "scope": "each", "paths": entries }))? + } else { + let path = file(app, d)?; + app.run(CMD, json!({ "ids": d.fields.get("ids").cloned().unwrap_or(Value::Null), "path": path, "scope": scope }))? + }; + app.ui.dialog = None; + let missed: Vec<&str> = r["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + app.status(crate::panels::links::relinked_status_missed(r["relinked"].as_array().map_or(0, Vec::len), &missed)); + Ok(r) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let links = rows(d, "links"); + let paths = rows(d, "paths"); + let each = d.str("scope") == "each"; + widgets::list_box(ui, |ui| { + ui.set_width(ui.available_width()); + for (i, l) in links.iter().enumerate() { + ui.horizontal(|ui| { + ui.add_space(6.0); + // With each: the link the next OK picks a file for is in bold. + let font = if each && i == paths.len() { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) }; + ui.label(egui::RichText::new(l["name"].as_str().unwrap_or_default()).font(font).color(t.text_strong)); + if let Some(p) = paths.get(i).and_then(Value::as_str).filter(|_| each) { + let name = std::path::Path::new(p).file_name().map_or(p.into(), |n| n.to_string_lossy()); + ui.add(egui::Label::new(egui::RichText::new(format!("→ {name}")).size(11.5).color(t.text_dim)).truncate()).on_hover_text(p); + } + }); + } + }); + ui.add_space(8.0); + form::choice(ui, d, "scope", tl!("Replace:"), (LABEL, 260.0), &SCOPES); + false +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_engine::Session; + + fn frame(app: &mut VectorcraftApp) -> String { + crate::tests_labels::painted_text(app, |app, ui| super::super::show(app, ui.ctx())) + } + + fn png(rgb: [u8; 3]) -> Vec { + let mut out = vec![]; + let [r, g, b] = rgb; + image::RgbaImage::from_pixel(40, 30, image::Rgba([r, g, b, 255])) + .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) + .unwrap(); + out + } + + /// A fresh folder with the PNG files `names` → (app with a document, folder). + fn setup(name: &str, names: &[&str]) -> (VectorcraftApp, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("vectorcraft-relink-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("new")).unwrap(); + for n in names { + std::fs::write(dir.join(n), png([200, 0, 0])).unwrap(); + } + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.services.read = Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + (app, dir) + } + + fn place(app: &mut VectorcraftApp, path: &std::path::Path) -> u64 { + app.run("file.place", json!({"path": path.to_string_lossy()})).unwrap()["ids"][0].as_u64().unwrap() + } + + fn link_path(app: &VectorcraftApp, id: u64) -> String { + let st = app.session.active().unwrap(); + match st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) { + Some(vectorcraft_doc::NodeKind::Image(im)) => im.link.as_ref().map(|l| l.path.clone()).unwrap_or_default(), + _ => String::new(), + } + } + + fn new_file(dir: &std::path::Path, name: &str) -> String { + let p = dir.join("new").join(name); + std::fs::write(&p, png([0, 0, 200])).unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn relink_opens_with_the_replace_choice_and_all_instances_relinks_every_copy() { + let (mut app, dir) = setup("all", &["photo.png", "other.png"]); + let (a, b, c) = (place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("photo.png")), place(&mut app, &dir.join("other.png"))); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.services.pick_open = None; + assert!(!crate::menus::enabled(&app, "ui.relinkDialog"), "no file picker (the web)"); + let picked = new_file(&dir, "blue.png"); + let answer = picked.clone(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(answer.clone()))); + assert!(crate::menus::enabled(&app, "ui.relinkDialog")); + app.run("ui.relinkDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + assert_eq!((d.kind.as_str(), d.str("scope"), d.fields["links"].as_array().unwrap().len()), (KIND, "selected".to_string(), 1)); + let text = frame(&mut app); + for label in ["Relink", "photo.png", "Replace:", "Selected Instances", "Relink…", "Cancel"] { + assert!(text.contains(label), "{label} in {text}"); + } + app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("all")); + // OK picks the file. + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["relinked"], json!([a, b])); + assert!(app.ui.dialog.is_none()); + assert_eq!((link_path(&app, a), link_path(&app, b)), (picked.clone(), picked.clone())); + assert_eq!(link_path(&app, c), dir.join("other.png").to_string_lossy()); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(link_path(&app, b), dir.join("photo.png").to_string_lossy(), "one undo step"); + // Nothing selected and no ids: nothing to relink. + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(!crate::menus::enabled(&app, "ui.relinkDialog")); + assert!(app.run("ui.relinkDialog", json!({})).is_err()); + assert!(app.run("ui.relinkDialog", json!({"ids": 3})).is_err(), "ids is a list"); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn each_link_separately_asks_for_a_file_for_each_link_then_relinks_them_all() { + let (mut app, dir) = setup("each", &["one.png", "two.png"]); + let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("two.png"))); + let (x, y) = (new_file(&dir, "x.png"), new_file(&dir, "y.png")); + let mut answers = vec![y.clone(), x.clone()]; + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| answers.pop())); + app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap(); + app.ui.dialog.as_mut().unwrap().fields.insert("scope".into(), json!("each")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r, json!({"next": "two.png"})); + let d = app.ui.dialog.as_ref().expect("asks for the next link's file"); + assert_eq!(d.fields["paths"], json!([x])); + assert_eq!(link_path(&app, a), dir.join("one.png").to_string_lossy(), "nothing relinked yet"); + assert!(frame(&mut app).contains("→ x.png")); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r["relinked"], json!([a, b])); + assert!(app.ui.dialog.is_none()); + assert_eq!((link_path(&app, a), link_path(&app, b)), (x.clone(), y.clone())); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!( + (link_path(&app, a), link_path(&app, b)), + (dir.join("one.png").to_string_lossy().into_owned(), dir.join("two.png").to_string_lossy().into_owned()), + "one undo step" + ); + let _ = std::fs::remove_dir_all(dir); + } + + /// An embedded image in the selection gets a row of its own, so Each Link Separately asks for + /// its file too. + #[test] + fn embedded_images_get_their_own_rows() { + let (mut app, dir) = setup("embedded", &["one.png"]); + let (a, b) = (place(&mut app, &dir.join("one.png")), place(&mut app, &dir.join("one.png"))); + app.run("links.embed", json!({"ids": [b]})).unwrap(); + app.run("ui.relinkDialog", json!({"ids": [a, b, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + let rows = d.fields["links"].as_array().unwrap(); + assert_eq!((rows.len(), &rows[0]["ids"], &rows[1]["ids"]), (2, &json!([a]), &json!([b]))); + let _ = std::fs::remove_dir_all(dir); + } + + /// Objects showing different content of one file share its row, so Each Link Separately asks + /// for that file once. + #[test] + fn one_file_with_two_contents_is_one_row() { + let (mut app, dir) = setup("one-file", &["photo.png"]); + let a = place(&mut app, &dir.join("photo.png")); + std::fs::write(dir.join("photo.png"), png([0, 200, 0])).unwrap(); + let b = place(&mut app, &dir.join("photo.png")); + app.run("ui.relinkDialog", json!({"ids": [a, b]})).unwrap(); + let d = app.ui.dialog.as_ref().expect("the dialog"); + assert_eq!(d.fields["links"], json!([{"name": "photo.png", "ids": [a, b]}])); + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn missing_links_from_this_folder_relinks_the_others_found_there() { + let (mut app, dir) = setup("missing", &["a.png", "b.png"]); + let (a, b) = (place(&mut app, &dir.join("a.png")), place(&mut app, &dir.join("b.png"))); + let doc = dir.join("doc.vectorcraft").to_string_lossy().into_owned(); + app.run("document.save", json!({"path": doc})).unwrap(); + for n in ["a.png", "b.png"] { + std::fs::remove_file(dir.join(n)).unwrap(); + } + app.run("file.close", json!({})).unwrap(); + crate::io::open_path(&mut app, &doc).unwrap(); + // The Linked File Not Found question is left for the Links panel. + app.ui.dialog = None; + let chosen = new_file(&dir, "renamed.png"); + let found = new_file(&dir, "b.png"); + app.run("ui.relinkDialog", json!({"ids": [a]})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("scope".into(), json!("missingInFolder")); + d.fields.insert("path".into(), json!(chosen)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!(r, json!({"relinked": [a, b], "notFound": []})); + assert_eq!((link_path(&app, a), link_path(&app, b)), (chosen, found)); + let _ = std::fs::remove_dir_all(dir); + } +} diff --git a/crates/ui-egui/src/dialogs/revolve.rs b/crates/ui-egui/src/dialogs/revolve.rs index 7146117b0..f3c7d7e35 100644 --- a/crates/ui-egui/src/dialogs/revolve.rs +++ b/crates/ui-egui/src/dialogs/revolve.rs @@ -60,7 +60,10 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); } -fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { +/// The tabs and controls of the 3D effect in `d.fields["__effect"]`, editing `d.fields`; the +/// dialog's body and the 3D and Materials panel draw them. With `__panel` set (the panel), the +/// controls that only the dialog's artboard gizmos use are left out. +pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.spacing_mut().item_spacing.y = 4.0; let id = d.str("__effect"); if id != REVOLVE { @@ -95,14 +98,13 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { }); number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit)); ui.horizontal(|ui| { - let mut cap = d.bool("cap"); + let cap = flag(d, "cap", true); let tip = tl!("Close the circular ends of open profiles. Partial sweeps keep their cut sides open."); - if widgets::check_tip(ui, c3("Cap"), cap, true, tip) { - cap = !cap; - d.fields.insert("cap".into(), json!(cap)); + if widgets::check3_tip(ui, c3("Cap"), cap, true, tip) { + d.fields.insert("cap".into(), json!(cap != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "cap", !cap, "true") { + if reset_dot(ui, "cap", cap != Some(true), "true") { d.fields.insert("cap".into(), json!(true)); } }); @@ -115,20 +117,22 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(6.0); egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| { number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None); - super::form::check(ui, d, "__showAxis", tl!("Show axis")); + if !d.bool("__panel") { + super::form::check(ui, d, "__showAxis", tl!("Show axis")); + } ui.add_space(6.0); ui.horizontal(|ui| { - let visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true); + let visible = flag(d, "expandVisibleOnly", true); let tip = tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces."); let toggled = ui.allocate_ui_with_layout( vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - widgets::check_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip) + widgets::check3_tip(ui, tl!("Keep visible surfaces only when expanding"), visible, true, tip) }, ).inner; if toggled { - d.fields.insert("expandVisibleOnly".into(), json!(!visible)); + d.fields.insert("expandVisibleOnly".into(), json!(visible != Some(true))); } - if reset_dot(ui, "expandVisibleOnly", !visible, "true") { + if reset_dot(ui, "expandVisibleOnly", visible != Some(true), "true") { d.fields.insert("expandVisibleOnly".into(), json!(true)); } }); @@ -248,7 +252,9 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) { fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) { let id = d.str("__effect"); let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null); - let value = d.fields.get(key).cloned().unwrap_or_else(|| default.clone()); + // A mixed setting (null) shows its default, with its reset dot filled. + let mixed = d.fields.get(key).is_some_and(Value::is_null); + let value = d.fields.get(key).filter(|v| !v.is_null()).cloned().unwrap_or_else(|| default.clone()); ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 6.0; label(ui, name); @@ -261,38 +267,48 @@ fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[( d.fields.insert(key.into(), v.clone()); } let default_label = options.iter().find(|(v, _)| *v == default).map_or("", |(_, l)| *l); - if reset_dot(ui, key, value != default, default_label) { + if reset_dot(ui, key, mixed || value != default, default_label) { d.fields.insert(key.into(), default.clone()); } }); } -/// A checkbox for `key`, with its reset dot. +/// The checkbox setting `key`: `None` when it is null (the 3D and Materials panel's selected +/// effects hold it with different values), else its value, or `default` when it has none. +fn flag(d: &Dialog, key: &str, default: bool) -> Option { + match d.fields.get(key) { + Some(Value::Null) => None, + value => Some(value.and_then(Value::as_bool).unwrap_or(default)), + } +} + +/// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on. fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) { ui.horizontal(|ui| { - let on = d.bool(key); - if widgets::check(ui, name, on, ui.is_enabled()) { - d.fields.insert(key.into(), json!(!on)); + let on = flag(d, key, default); + if widgets::check3(ui, name, on, ui.is_enabled()) { + d.fields.insert(key.into(), json!(on != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, key, on != default, if default { "true" } else { "false" }) { + if reset_dot(ui, key, on != Some(default), if default { "true" } else { "false" }) { d.fields.insert(key.into(), json!(default)); } }); } fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) { + let shade = flag(d, "shade", true); ui.horizontal(|ui| { - let shade = d.bool("shade"); - if widgets::check(ui, tl!("Shading"), shade, true) { - d.fields.insert("shade".into(), json!(!shade)); + if widgets::check3(ui, tl!("Shading"), shade, true) { + d.fields.insert("shade".into(), json!(shade != Some(true))); } ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "shade", !shade, "true") { + if reset_dot(ui, "shade", shade != Some(true), "true") { d.fields.insert("shade".into(), json!(true)); } }); - ui.add_enabled_ui(d.bool("shade"), |ui| { + // A mixed Shading leaves the light settings on, for the effects that shade. + ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| { lighting_presets(ui, d); light_list(ui, d); let mut selected = selected_light(d); @@ -413,8 +429,10 @@ fn light_list(ui: &mut egui::Ui, d: &mut Dialog) { super::revolve_gizmo::clear(ui.ctx()); } }); + // Add Light is off while a setting of the single light is mixed (null). + let mixed = explicit_lights(d).is_none() && LIGHT_KEYS.iter().any(|(_, key)| d.fields.get(*key).is_some_and(Value::is_null)); ui.horizontal(|ui| { - if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS, egui::Button::new(tl!("Add Light"))).clicked() { + if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS && !mixed, egui::Button::new(tl!("Add Light"))).clicked() { let mut added = selected_light(d); let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({})); for (_, key) in LIGHT_KEYS { @@ -458,7 +476,8 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) { } ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2])); ui.add_space((ui.available_width() - 24.0).max(0.0)); - if reset_dot(ui, "lightColor", rgb != [255; 3], "#FFFFFF") { + let mixed = d.fields.get("lightColor").is_some_and(Value::is_null); + if reset_dot(ui, "lightColor", mixed || rgb != [255; 3], "#FFFFFF") { d.fields.insert("lightColor".into(), json!("#ffffff")); } }); @@ -550,7 +569,9 @@ fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, if let Some(n) = slider.0.map(|n| rail_min + f64::from(n) * (rail_max - rail_min)).or(slider.2) { next = Some(n); } - if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { + // A setting the 3D and Materials panel's selected effects hold with different values is null. + let mixed = d.fields.get(key).is_some_and(Value::is_null); + if reset_dot(ui, key, mixed || (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) { next = Some(from(default)); } }); diff --git a/crates/ui-egui/src/dialogs/symbol_options.rs b/crates/ui-egui/src/dialogs/symbol_options.rs index f388cb4ec..79e26764d 100644 --- a/crates/ui-egui/src/dialogs/symbol_options.rs +++ b/crates/ui-egui/src/dialogs/symbol_options.rs @@ -123,7 +123,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.end_row(); label(ui, tl!("Registration:")); let cur = d.fields.get("registration").and_then(Value::as_u64).unwrap_or(4) as usize; - if let Some(i) = widgets::reference_point(ui, cur) { + if let Some(i) = widgets::reference_point_named(ui, cur, tl!("Registration:")) { d.fields.insert("registration".into(), json!(i)); } ui.end_row(); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 5c9b74c85..51959c320 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -463,6 +463,39 @@ fn dialogs_do_not_stretch_to_the_screen() { assert!(checked >= 10, "only {checked} dialogs drew in the shared frame"); } +#[test] +fn workspace_and_toolbar_dialogs_end_at_their_buttons() { + // New Workspace, Manage Workspaces, New Toolbar and Manage Toolbars draw their own windows: each + // ends just below its OK button, with no empty band above or below the buttons. + for (kind, fields, window) in [ + ("newWorkspace", json!({"name": "Mine"}), "dialog-workspace"), + ("manageWorkspaces", json!({"selected": "", "name": ""}), "dialog-workspace"), + (crate::custom_toolbars::KIND, json!({"name": "Toolbar 1"}), "dialog-new-toolbar"), + (crate::custom_toolbars::MANAGE, json!({"selected": "", "name": ""}), "dialog-manage-toolbars"), + ] { + let mut app = app(); + open(&mut app, kind, fields); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); + let mut ok = None; + for _ in 0..4 { + let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| show(&mut app, ui.ctx())); + out.textures_delta.clear(); + ok = out.shapes.iter().find_map(|c| match &c.shape { + egui::Shape::Text(t) if t.galley.text() == "OK" => Some(t.visual_bounding_rect()), + _ => None, + }); + } + let ok = ok.unwrap_or_else(|| panic!("{kind} draws no OK button")); + let outer = ctx.memory(|m| m.area_rect(egui::Id::unique(window))).unwrap(); + let below = outer.bottom() - ok.bottom(); + assert!(below < 40.0, "{kind}: {below} pt between OK and the bottom of its {} pt window", outer.height()); + } +} + /// The dialog window of `app`'s open dialog after a few frames in a `w` × 900 window. fn dialog_rect(app: &mut VectorcraftApp, w: f32) -> egui::Rect { let ctx = egui::Context::default(); @@ -1741,6 +1774,66 @@ fn artboard_options_show_center_mark_and_cross_hairs() { } } +/// Artboard Options shows Fade Region Outside Artboard and Update While Dragging under Global, +/// ticked as the preferences have them (on by default). OK sets the preferences, for the dialog of +/// an existing artboard and of a new one alike, and the artboard keeps its other settings. +#[test] +fn artboard_options_global_checkboxes_set_the_preferences() { + let mut app = app(); + let prefs = |app: &VectorcraftApp| (app.session.prefs.fade_region_outside_artboard, app.session.prefs.artboard_update_while_dragging); + assert_eq!(prefs(&app), (true, true)); + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let text = crate::tests_labels::painted_text(&mut app, |app, ui| show(app, ui.ctx())); + assert!(text.contains("Global") && text.contains("Fade Region Outside Artboard") && text.contains("Update While Dragging"), "{text}"); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(d.bool("fadeRegionOutsideArtboard") && d.bool("artboardUpdateWhileDragging")); + d.fields.insert("fadeRegionOutsideArtboard".into(), json!(false)); + let rect = app.session.doc().unwrap().doc.artboards[0].rect; + super::confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none()); + assert_eq!(prefs(&app), (false, true)); + assert_eq!(app.session.doc().unwrap().doc.artboards[0].rect, rect); + // Opened again, it shows them as set; a value that is not true or false is refused and changes + // nothing. A new artboard's dialog sets them too. + crate::dialogs::artboard_options::open(&mut app).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("fadeRegionOutsideArtboard")); + d.fields.insert("artboardUpdateWhileDragging".into(), json!("sometimes")); + assert!(super::confirm(&mut app).is_err()); + assert_eq!(prefs(&app), (false, true), "nothing changed"); + cancel(&mut app); + app.run("ui.newArtboard", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + assert!(!d.bool("fadeRegionOutsideArtboard") && d.bool("artboardUpdateWhileDragging")); + d.fields.insert("fadeRegionOutsideArtboard".into(), json!(true)); + d.fields.insert("artboardUpdateWhileDragging".into(), json!(false)); + super::confirm(&mut app).unwrap(); + assert_eq!(prefs(&app), (true, false)); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 2); +} + +/// When the artboard command refuses a value, OK leaves the Global checkboxes' preferences as they +/// were, for the dialog of an existing artboard and of a new one alike. +#[test] +fn artboard_options_error_leaves_the_global_preferences_unchanged() { + let mut app = app(); + let prefs = |app: &VectorcraftApp| (app.session.prefs.fade_region_outside_artboard, app.session.prefs.artboard_update_while_dragging); + for new in [false, true] { + if new { + app.run("ui.newArtboard", json!({})).unwrap(); + } else { + crate::dialogs::artboard_options::open(&mut app).unwrap(); + } + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("fadeRegionOutsideArtboard".into(), json!(false)); + d.fields.insert("videoRulerPixelAspectRatio".into(), json!(20)); + assert!(super::confirm(&mut app).is_err(), "new: {new}"); + assert_eq!(prefs(&app), (true, true), "new: {new}"); + assert_eq!(app.session.doc().unwrap().doc.artboards.len(), 1, "new: {new}"); + cancel(&mut app); + } +} + /// Artboard Options shows Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (one row of /// it), set as the artboard has them whether it opens from the Artboards panel or from a /// double-click with the Artboard tool. OK sets them in one undo step (a typed ratio is read as a diff --git a/crates/ui-egui/src/export_progress.rs b/crates/ui-egui/src/export_progress.rs index 02d1ab7ff..3d392c3a3 100644 --- a/crates/ui-egui/src/export_progress.rs +++ b/crates/ui-egui/src/export_progress.rs @@ -1,7 +1,8 @@ //! The export progress dialog: while an export runs in the background (Background Export) a modal //! window shows what is being exported, how far it has got (artboard by artboard for raster -//! exports of several artboards, file by file for Export for Screens; busy for one artboard and for -//! formats made in one go, which report no steps) and two buttons: Cancel (`file.cancelExport`, also +//! exports of several artboards, file by file for Export for Screens, as a share for an exporter +//! plug-in that reports its progress; busy for one artboard and for formats made in one go, which +//! report no steps) and two buttons: Cancel (`file.cancelExport`, also //! Esc) stops it and writes nothing (Export for Screens keeps the files it wrote before the //! Cancel), Continue in Background (`file.exportInBackground`) closes the //! dialog and leaves the export to the status bar. @@ -24,8 +25,8 @@ fn seen_id() -> egui::Id { } /// What the dialog shows: (label, steps done, steps in all, share done, exports after this one, -/// whether the steps are files). -fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, bool)> { +/// what the steps are). +fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, Steps)> { let jobs: Vec<_> = app.background.jobs.iter().filter(|j| !j.hidden && !j.cancelled()).filter(|j| j.progress.is_some()).collect(); let first = jobs.first()?; let since = ctx.data_mut(|m| { @@ -40,8 +41,22 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, } let (done, total) = first.progress.as_ref().map(|p| p.counts()).unwrap_or_default(); let frac = first.progress.as_ref().and_then(|p| p.fraction()).unwrap_or(0.0) as f32; - let files = first.progress.as_ref().is_some_and(|p| p.counts_files()); - Some((first.label.clone(), done, total, frac, jobs.len() - 1, files)) + let steps = match &first.progress { + Some(p) if p.counts_share() => Steps::Share, + Some(p) if p.counts_files() => Steps::Files, + _ => Steps::Artboards, + }; + Some((first.label.clone(), done, total, frac, jobs.len() - 1, steps)) +} + +/// What an export's steps are. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Steps { + Artboards, + /// Files (Export for Screens). + Files, + /// An exporter plug-in's own steps, shown as a share of the work. + Share, } /// Draws the window for the first export running in the background that wasn't hidden or @@ -49,7 +64,7 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, /// of the button pressed. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); - let Some((label, done, total, frac, more, files)) = shown(app, now, ctx) else { return }; + let Some((label, done, total, frac, more, steps)) = shown(app, now, ctx) else { return }; if app.ui.dialog.is_some() { return; } @@ -57,8 +72,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let heading = format!("{}…", crate::i18n::msg(&label)); let pressed = window(ctx, "export-progress", &heading, |ui| { // One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written. - if total > 1 { - let step = if files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") }; + if steps == Steps::Share && total > 0 { + share(ui, tl!("Encoding and writing the file…"), frac); + } else if total > 1 { + let step = if steps == Steps::Files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") }; let step = crate::i18n::fmt(step, &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]); // The count on its own line: drawn over the bar it would straddle the fill's edge. ui.horizontal(|ui| { @@ -126,6 +143,24 @@ pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&m pressed } +/// Work that reports the share it has done (0 to 1), in a [`window`]: `what` it does, in the UI +/// language, and the share as a percentage, over a bar filled that far. A plug-in's own steps are +/// shown this way. +pub fn share(ui: &mut egui::Ui, what: &str, done: f32) { + let t = Tokens::get(ui.ctx()); + let done = if done.is_finite() { done.clamp(0.0, 1.0) } else { 0.0 }; + ui.horizontal(|ui| { + ui.label(egui::RichText::new(what).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(format!("{:.0} %", done * 100.0)).color(t.text_dim)); + }); + }); + ui.add_space(4.0); + ui.add(egui::ProgressBar::new(done).desired_width(360.0).desired_height(6.0).fill(t.accent)); + // The work reports from another thread: draw what it reports next. + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); +} + /// Work that counts no steps, in a [`window`]: a spinner and `what` it does, in the UI language. pub fn busy(ui: &mut egui::Ui, what: &str) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 925865744..d6f19394d 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -70,6 +70,11 @@ 3 Color Job… Задача с 3 цвята… 30° Angles Ъгли от 30° 3D and Materials 3D и материали + Plane Равнина + Extrude Избутване + Revolve Завъртане около ос + Inflate Издуване + Select objects, then pick an object type to give them a 3D effect. Изберете обекти, след това изберете тип обект, за да им придадете 3D ефект. 3D Ribbon 3D лента 45° Angles Ъгли от 45° 60° Angles Ъгли от 60° @@ -271,6 +276,8 @@ font-technology Variable Променливи Show Cross Hairs Показване на кръстосани линии Show Video Safe Areas Показване на безопасните видеозони Video Ruler Pixel Aspect Ratio: Пропорция на пиксела на видеолинийките: + Fade Region Outside Artboard Затъмняване на областта извън работната област + Update While Dragging Обновяване при плъзгане Hide Video Rulers Скриване на видеолинийките Background Contents Съдържание на фона Background Contents: Съдържание на фона: @@ -1329,6 +1336,8 @@ font-technology Variable Променливи Grays Сиви Grays and Neutrals Сиви и неутрални Grayscale Черно-бяло + High Color Много цветове + Low Color Малко цветове Grayscale images Черно-бели изображения Greek & Cyrillic Гръцки и кирилица Green Зелено @@ -1401,6 +1410,7 @@ font-technology Variable Променливи Step Backward Стъпка назад Step Forward Стъпка напред Clear History Изчистване на историята + New Document from State Нов документ от състоянието Set History Limit… Задаване на ограничение на историята… History States Състояния на историята # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1763,6 +1773,9 @@ font-technology Variable Променливи Live Paint Selection Tool Инструмент за селекция на динамично оцветяване Live Shape Properties Свойства на динамична фигура Load Color Profile… Зареждане на файлов цвят… + Load Character Styles… Зареждане на стилове на знаци… + Load Paragraph Styles… Зареждане на стилове на абзац… + Load All Styles… Зареждане на всички стилове… Remove Color Profile… Премахване на цветови профил… Load Image into Clipboard Зареждане на изображение в клипборда Load Metafile into Clipboard Зареждане на метафайл в клипборда @@ -2625,6 +2638,14 @@ digits Malayalam Малаялам Relink Свързване наново Relink Linked Files to the Document Свързване наново на свързаните файлове към документа Relink to Folder… Свързване наново към папка… + Each Link Separately Всяка връзка поотделно + Selected Instances Избраните екземпляри + All Instances Всички екземпляри + Missing Links from This Folder Липсващите връзки от тази папка +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не е each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each и paths вървят заедно: paths изброява записи с ids и path вместо ids, path и folder +@msg paths entry {i} needs path and ids (object ids no other entry has) записът {i} в paths изисква path и ids (идентификатори на обекти, които няма друг запис) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder приема path: в папката на този файл се търсят другите липсващи връзки Relink… Свързване наново… Find Linked Files Търсене на свързани файлове Find Linked Files in Folder… Търсене на свързани файлове в папка… @@ -2669,6 +2690,7 @@ digits Malayalam Малаялам Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Заменете го с друг файл или го игнорирайте: неговите изображения ще продължат да показват записания с документа преглед. Replace Spine Замяна на оста Replace Symbol Замяна на символ + Reset Transformation Нулиране на трансформацията Replace With Font Замяна с шрифт Replace With Font From Замяна с шрифт от System Система @@ -2685,6 +2707,8 @@ digits Malayalam Малаялам Reset OpenType Features Нулиране на OpenType характеристиките Reset Panel Нулиране на панела Reset Preferences Нулиране на настройките + Search Preferences Търсене в настройките + No settings found Няма намерени настройки Reset Profiles Нулиране на профилите Reset Rotate View Нулиране на изгледа на завъртане Rotate View to Selection Завъртане на изгледа спрямо селекцията @@ -2739,6 +2763,7 @@ digits Malayalam Малаялам Rotate Tool Инструмент „Завъртане“ Rotate View Tool Инструмент „Завъртане на изглед“ Trackpad Gesture to Rotate View Завъртане на изгледа с жест на тракпада + Hold a Tool Shortcut to Use the Tool Temporarily Задържане на клавиша на инструмент за временното му използване Rotate… Завъртане… Rotation Завъртане Roughen… Грапавост… @@ -3164,6 +3189,7 @@ digits Malayalam Малаялам Spaces Интервали Spacing Guides Водачи за разстояние Invoke Distance Guides Извикване на водачи за отстояния + Snap to Last Location Прилепване към последното местоположение Spacing: Разстояние: Spanish Испански Specified Distance Упоменато разстояние @@ -3843,6 +3869,9 @@ axis Both И двете Put back in the toolbar Връщане в лентата с инструменти Float Panel Плаващ панел Dock Panel Закотвен панел + Dock to Top Закотвяне горе + Dock to Bottom Закотвяне долу + Dock Control Bar Закотвяне на контролната лента Drag to float the toolbar Плъзнете, за да направите лентата с инструменти плаваща Drag to the window's left edge to dock Плъзнете до левия ръб на прозореца за закотвяне Float Tool Group Плаваща група инструменти @@ -4241,6 +4270,7 @@ axis Both И двете @msg nothing to save: the document has no visible art няма нищо за запазване: документът няма видима графика @msg nothing to undo няма какво да се отмени @msg no history to clear няма история за изчистване +@msg no history state {v} няма състояние в историята {v} @msg nothing to warp няма нищо за извиване @msg nothing to wrap няма нищо за обвиване @msg object {0} cannot have children обектът {0} не може да има дъщерни елементи @@ -4603,6 +4633,7 @@ axis Both И двете ICF Box Касетка ICF @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` е референцията за стъпка {r}, която се разрешава само от `run` и `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` трябва да бъде масив от идентификатори на обекти, а не {v} +@msg `colors` must be an array of colors, not {v} `colors` трябва да бъде масив от цветове, а не {v} @msg `corners` must be an array of anchor indices, not {v} `corners` трябва да бъде масив от индекси на опорни точки, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: резултатът от стъпка {i} няма такава стойност (напишете $$ за литерален знак $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: няма стъпка {i} преди тази (стъпките се броят от 0; напишете $$ за литерален знак $) @@ -4731,10 +4762,10 @@ effect Soft Меко @msg anchor must name a registered panel опорната точка трябва да указва регистриран панел @msg before must name a panel параметърът „преди“ трябва да указва панел @msg docking action contains an unknown panel or an excessive split path действието по закачане съдържа непознат панел или прекомерен път на разделяне -@msg group must be true or false групата трябва да е истина или лъжа +@msg group must be true or false group трябва да е true или false @msg panel is not in a dock group панелът не е в група на дока @msg only a dock group above or below others collapses, not one beside another свива се само група на дока над или под други, не и такава до друга -@msg icon must be true or false icon трябва да е истина или лъжа +@msg icon must be true or false icon трябва да е true или false @msg icon only docks a panel in the icon column, with no anchor icon закачва панел само в колоната с икони, без anchor @msg column docks a set as a dock column, with no anchor or icon column закачва набор като колона на дока, без anchor или icon @msg move requires a destination panel преместването изисква целеви панел @@ -5685,6 +5716,7 @@ axis Y Axis Y ос Keep data of its own with the document Съхраняване на собствени данни в документа Change how the objects its effect is applied to are drawn Промяна на начина, по който се рисуват обектите, към които е приложен ефектът му Write the document as a file in its own format Записване на документа като файл в собствен формат + Show its progress, see Cancel and write log lines Показване на напредъка, разпознаване на отказ и записване на редове в дневника # Actions panel: action sets (#3712). New Set… Нов набор… New Set Нов набор @@ -6110,6 +6142,7 @@ breakText Glyphs Глифи Gap 5 Разстояние 5 @msg {_1}: they came in as the page's art {_1}: те са внесени като изображението от страницата +@msg warped text objects that came in as drawn art, not as editable type: {_1} изкривени текстови обекти, внесени като изображение, а не като редактируем текст: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} обектите, изобразени различно от собствената страница на файла, са внесени като изображението от страницата: {_1} @msg You must choose a side pattern. Трябва да изберете страничен шаблон. diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index c69f6ea9a..a30637ecb 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -448,6 +448,11 @@ Document Raster Effects Settings… Postavke rasterskih efekata dokumenta… Vector Effects Vektorski efekti 3D and Materials 3D i materijali + Plane Ravan + Extrude Istiskivanje + Revolve Rotaciono tijelo + Inflate Napuhni + Select objects, then pick an object type to give them a 3D effect. Odaberite objekte, zatim odaberite vrstu objekta da im date 3D efekat. Extrude & Bevel… Istiskivanje i zakošenje… Revolve… Rotaciono tijelo… Inflate… Napuhni… @@ -809,6 +814,9 @@ Put back in the toolbar Vrati u traku s alatima Float Panel Odvoji panel Dock Panel Usidri panel + Dock to Top Usidri na vrh + Dock to Bottom Usidri na dno + Dock Control Bar Usidri kontrolnu traku Float Tool Group Odvoji grupu alata Services Usluge Hide VectorCraft Sakrij VectorCraft @@ -1290,6 +1298,7 @@ lighting Light Svjetlo Rotate Tool Alat za rotaciju Rotate View Tool Alat za rotaciju prikaza Trackpad Gesture to Rotate View Pokret dodirne ploče za rotaciju prikaza + Hold a Tool Shortcut to Use the Tool Temporarily Držite prečicu alata za privremenu upotrebu alata Rotate View Rotiraj prikaz Rounded Rectangle Tool Alat zaobljeni pravougaonik Scale Tool Alat za skaliranje @@ -1612,6 +1621,7 @@ digits Malayalam Malajalam Step Backward Korak unazad Step Forward Korak naprijed Clear History Očisti historiju + New Document from State Novi dokument iz stanja Set History Limit… Postavi ograničenje historije… Open a document to see its history. Otvorite dokument da vidite njegovu historiju. Apply Data Set Primijeni skup podataka @@ -2057,6 +2067,14 @@ digits Malayalam Malajalam Location Lokacija No images Nema slika Relink to Folder… Ponovo poveži s folderom… + Each Link Separately Svaku vezu zasebno + Selected Instances Odabrane instance + All Instances Sve instance + Missing Links from This Folder Nedostajuće veze iz ovog foldera +@msg scope {v} is not each|selected|all|missingInFolder scope {v} nije each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths idu zajedno: paths navodi unose s ids i path, umjesto ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) unos {i} u paths treba path i ids (ID-ove objekata koje nema nijedan drugi unos) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder uzima path: u folderu te datoteke traže se ostale nedostajuće veze Show Missing Prikaži nedostajuće Sort by Status Sortiraj po statusu Clear Overrides Očisti zamjene @@ -2558,6 +2576,8 @@ digits Malayalam Malajalam Show Cross Hairs Prikaži končanicu Show Video Safe Areas Prikaži sigurne oblasti za video Video Ruler Pixel Aspect Ratio: Omjer stranica piksela video ravnala: + Fade Region Outside Artboard Zatamni područje izvan radne ploče + Update While Dragging Ažuriraj tokom povlačenja Hide Video Rulers Sakrij video ravnala Client-side (.html) Na strani klijenta (.html) Color Reduction: Smanjenje boja: @@ -3162,6 +3182,7 @@ theme Appearance Izgled Redefine Symbol Ponovo definiši simbol Remove Brush Stroke Ukloni potez četkicom Replace Symbol Zamijeni simbol + Reset Transformation Vrati transformaciju na početno Select All Instances Odaberi sve instance Symbol Sprayer Raspršivač simbola Symbolism Tool Alat simbolizam @@ -3632,6 +3653,8 @@ theme Appearance Izgled Real-time Drawing and Editing Crtanje i uređivanje u stvarnom vremenu Render Threads (-1 = Automatic) Niti iscrtavanja (-1 = automatski) Reset Preferences Vrati postavke na početne + Search Preferences Pretraga postavki + No settings found Nema pronađenih postavki Scale Corners Skaliraj uglove Scale Cursor Proportional to UI Skaliraj kursor proporcionalno interfejsu Scale Strokes & Effects Skaliraj poteze i efekte @@ -3652,6 +3675,7 @@ theme Appearance Izgled Snapping Tolerance Tolerancija hvatanja Spacing Guides Vodilice razmaka Invoke Distance Guides Pozovi vodiče udaljenosti + Snap to Last Location Prilijepi uz posljednju lokaciju Spanish Španski Subdivisions Podjele Templates Folder Folder predložaka @@ -3819,6 +3843,8 @@ axis Both Obje Center Sredina Transform in Perspective Transformiši u perspektivi Grayscale Nijanse sive + High Color Mnogo boja + Low Color Malo boja Embed Ugradi Star Zvijezda Power Saving (integrated) Ušteda energije (integrisana) @@ -4637,6 +4663,7 @@ imageTrace Outline Obris @msg nothing to save: the document has no visible art nema ničeg za čuvanje: dokument nema vidljiv crtež @msg nothing to undo nema ničeg za poništavanje @msg no history to clear nema historije za brisanje +@msg no history state {v} nema stanja historije {v} @msg nothing to warp nema ničeg za iskrivljenje @msg nothing to wrap nema ničeg za omotavanje @msg object {0} cannot have children objekat {0} ne može imati djecu @@ -4961,6 +4988,7 @@ imageTrace Outline Obris @msg No linked files to look for Nema povezanih datoteka za traženje @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je referenca na korak {r}, koju razrješavaju samo `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` mora biti niz ID-ova objekata, a ne {v} +@msg `colors` must be an array of colors, not {v} `colors` mora biti niz boja, a ne {v} @msg `corners` must be an array of anchor indices, not {v} `corners` mora biti niz indeksa sidara, a ne {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: rezultat koraka {i} nema takvu vrijednost (napišite $$ za doslovni $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: prije ovog nema koraka {i} (koraci se broje od 0; napišite $$ za doslovni $) @@ -5709,6 +5737,11 @@ axis Y Axis Y osa Keep data of its own with the document Čuvati vlastite podatke uz dokument Change how the objects its effect is applied to are drawn Mijenjati kako se crtaju objekti na koje je primijenjen njegov efekt Write the document as a file in its own format Zapisati dokument kao datoteku u vlastitom formatu + Show its progress, see Cancel and write log lines Prikazati napredak, prepoznati otkazivanje i zapisivati redove dnevnika +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} dozvola `progress` zahtijeva nivo API-ja dodataka {PROGRESS_API}: navedite `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul uvozi `env.{name}` s drugim tipom od domaćinovog: domaćinov tip je {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul uvozi `env.{name}`, koji dodatak smije koristiti samo na nivou API-ja dodataka {PROGRESS_API} (`api` {PROGRESS_API}) i s dozvolom `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul uvozi `{from}.{name}`, koji domaćin ne nudi: dodatak smije uvoziti samo `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivo API-ja dodataka 2), a nikakav WASI, datoteke ni mrežu @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; ovaj domaćin podržava nivoe API-ja dodataka od 1 do {API_LEVEL} @msg `grant` must be a list of permission names `grant` mora biti lista naziva dozvola @msg `grant` names an unknown permission `{shown}` `grant` navodi nepoznatu dozvolu `{shown}` @@ -6156,6 +6189,7 @@ shape Open Otvoren @msg the paths have nothing to merge there putanje tu nemaju šta spojiti @msg the path is locked or hidden putanja je zaključana ili skrivena @msg {_1}: they came in as the page's art {_1}: uvezeni su kao ilustracija stranice +@msg warped text objects that came in as drawn art, not as editable type: {_1} iskrivljeni tekstualni objekti uvezeni kao ilustracija, a ne kao tekst koji se može uređivati: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekti koji su se iscrtali drugačije od vlastite stranice datoteke uvezeni su kao ilustracija stranice: {_1} @msg You must choose a side pattern. Morate odabrati bočni uzorak. diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 222971582..953914129 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -107,6 +107,11 @@ 32 bit 32 bits 32 bits/pixel 32 bits/píxel 3D and Materials 3D i materials + Plane Pla + Extrude Extrusió + Revolve Revolució + Inflate Infla + Select objects, then pick an object type to give them a 3D effect. Seleccioneu objectes i després trieu un tipus d’objecte per donar-los un efecte 3D. 3D Lighting Preset Preajust d’il·luminació 3D 3D Ribbon Cinta 3D 4 bit 4 bits @@ -652,6 +657,7 @@ Clear Events Esborra els esdeveniments Clear Guides Esborra les guies Clear History Esborra l’historial + New Document from State Document nou a partir de l’estat Clear Overrides Esborra les modificacions Clear Recent Files Esborra els fitxers recents Click Fes clic @@ -1165,6 +1171,9 @@ Do Not Scale No escalis Do you want to save the changes you made to “{name}”? Voleu desar els canvis que heu fet a “{name}”? Dock Panel Acobla el panell + Dock to Top Acobla a dalt + Dock to Bottom Acobla a baix + Dock Control Bar Acobla la barra de control Document Document Document Color Mode Mode de color del document Document Info Informació del document @@ -1774,6 +1783,8 @@ digits Malayalam Malaiàlam Grays Grisos Grays and Neutrals Grisos i neutres Grayscale Escala de grisos + High Color Molts colors + Low Color Pocs colors Grayscale images Imatges en escala de grisos Greek & Cyrillic Grec i ciríl·lic Green Verd @@ -2031,6 +2042,7 @@ digits Malayalam Malaiàlam Invert Pie Inverteix el diagrama circular Inverted Round Rodó invertit Invoke Distance Guides Invoca les guies de distància + Snap to Last Location Ajusta a l’última ubicació Isolate Aïlla Isolate Blending Aïlla la fusió Isolate Group Aïlla el grup @@ -3313,6 +3325,14 @@ digits Malayalam Malaiàlam Relink Torna a enllaçar Relink Linked Files to the Document Torna a enllaçar els fitxers enllaçats al document Relink to Folder… Torna a enllaçar a una carpeta… + Each Link Separately Cada enllaç per separat + Selected Instances Instàncies seleccionades + All Instances Totes les instàncies + Missing Links from This Folder Enllaços que falten d’aquesta carpeta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} no és each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths van junts: paths llista entrades amb ids i path, en lloc d’ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrada {i} de paths necessita path i ids (identificadors d’objecte que cap altra entrada no tingui) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder pren path: a la carpeta d’aquest fitxer es cerquen els altres enllaços que falten Relink… Torna a enllaçar… Reload Plug-ins Recarrega els complements Remember Import Matches Recorda les coincidències d’importació @@ -3371,6 +3391,7 @@ digits Malayalam Malaiàlam Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplaça’l per un altre fitxer o ignora’l: les seves imatges continuaran mostrant la previsualització desada amb el document. Replace Spine Reemplaça l’espina Replace Symbol Reemplaça el símbol + Reset Transformation Restableix la transformació Replace With Font Reemplaça per un tipus de lletra Replace With Font From Reemplaça per un tipus de lletra de System Sistema @@ -3398,6 +3419,8 @@ digits Malayalam Malaiàlam Reset Painting Restableix Pintura Reset Panel Restableix el panell Reset Preferences Restableix les preferències + Search Preferences Cerca a les preferències + No settings found No s'ha trobat cap paràmetre Reset Printing and Proofing Restableix impressió i prova Reset Profiles Restableix els perfils Reset Rotate View Restableix la rotació de la vista @@ -4414,6 +4437,7 @@ digits Malayalam Malaiàlam Tracing Result with Outlines Resultat del traçat amb contorns Tracking Espaiat Trackpad Gesture to Rotate View Gesticulació del trackpad per girar la vista + Hold a Tool Shortcut to Use the Tool Temporarily Mantén premuda la drecera d’una eina per usar-la temporalment Trademark Symbol Símbol de marca comercial Transform Transforma Transform Again Transforma de nou @@ -4673,6 +4697,8 @@ digits Malayalam Malaiàlam Video 3840×2160 Vídeo 3840×2160 Video Banner 2560×1440 Bàner de vídeo 2560×1440 Video Ruler Pixel Aspect Ratio: Relació d’aspecte de píxel de les regles de vídeo: + Fade Region Outside Artboard Enfosqueix la regió fora del tauler + Update While Dragging Actualitza mentre s’arrossega Video Thumbnail 1280×720 Miniatura de vídeo 1280×720 View Visualitza View Artboard Only Visualitza només el tauler @@ -4850,7 +4876,7 @@ digits Malayalam Malaiàlam @msg `Custom` stands for changed settings: save the preset under another name `Custom` representa una configuració modificada: deseu el preajust amb un altre nom @msg `dither` applies to gradient fills (paint.setFill) `dither` s’aplica als farciments de degradat (paint.setFill) @msg `dither` applies to gradient fills (stroke: false) `dither` s’aplica als farciments de degradat (stroke: false) -@msg `dither` must be true or false `dither` ha de ser cert o fals +@msg `dither` must be true or false `dither` ha de ser true o false @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` té {size} bytes (límit {MAX_DOCUMENT_DATA}) @msg `freeform.lines` must be an array `freeform.lines` ha de ser una matriu @msg `freeform.points` must be an array `freeform.points` ha de ser una matriu @@ -4858,6 +4884,7 @@ digits Malayalam Malaiàlam @msg `groupColors` must be an array `groupColors` ha de ser una matriu @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` és la referència del pas {r}, que només resolen `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` ha de ser una matriu d’identificadors d’objecte, no {v} +@msg `colors` must be an array of colors, not {v} `colors` ha de ser una matriu de colors, no {v} @msg `library` is a library id or name (see {list}) `library` és un identificador o un nom de biblioteca (vegeu {list}) @msg `method` must be classic or perceptual `method` ha de ser classic o perceptual @msg `minAnchors` and `maxAnchors` must be whole numbers `minAnchors` i `maxAnchors` han de ser nombres enters @@ -4869,7 +4896,7 @@ digits Malayalam Malaiàlam @msg `preserve` must be an object `preserve` ha de ser un objecte @msg `globalAdjust` must be an object `globalAdjust` ha de ser un objecte @msg `sampleSize` must be 1, 3 or 5 `sampleSize` ha de ser 1, 3 o 5 -@msg `select` must be true or false `select` ha de ser cert o fals +@msg `select` must be true or false `select` ha de ser true o false @msg `stops` must be an array `stops` ha de ser una matriu @msg `svg` must be an object of SVG options (see document.formats) `svg` ha de ser un objecte d’opcions SVG (vegeu document.formats) @msg `to` must be "corner" or "smooth" `to` ha de ser "corner" o "smooth" @@ -4896,7 +4923,7 @@ digits Malayalam Malaiàlam @msg `{key}` must be an array of these names: {_1} `{key}` ha de ser una matriu d’aquests noms: {_1} @msg `{key}` must be between {min} and {max} (got {x}) `{key}` ha d’estar entre {min} i {max} (rebut {x}) @msg `{key}` must be one of {_1} `{key}` ha de ser un de {_1} -@msg `{key}` must be true or false `{key}` ha de ser cert o fals +@msg `{key}` must be true or false `{key}` ha de ser true o false @msg `{k}` must be [x, y] `{k}` ha de ser [x, y] @msg `{min_key}` is greater than `{max_key}` `{min_key}` és més gran que `{max_key}` @msg `{name}` has no options `{name}` no té opcions @@ -5049,7 +5076,7 @@ digits Malayalam Malaiàlam @msg colorTable.{what}: `{h}` is not a colour such as "#ff8800" colorTable.{what}: `{h}` no és un color com "#ff8800" @msg colorTable.{what}: at most {MAX_TABLE} colours colorTable.{what}: com a màxim {MAX_TABLE} colors @msg column docks a set as a dock column, with no anchor or icon column acobla un conjunt com a columna d’acoblament, sense àncora ni icona -@msg column must be true or a number from 1 column ha de ser cert o un nombre a partir d’1 +@msg column must be true or a number from 1 column ha de ser true o un nombre a partir d’1 @msg Combine Slices: the slices have no bounds Combina els talls: els talls no tenen límits @msg command `{0}` is not available right now: {1} l’ordre `{0}` no està disponible ara mateix: {1} @msg compound paths can only contain paths els traçats compostos només poden contenir traçats @@ -5286,6 +5313,7 @@ digits Malayalam Malaiàlam @msg no graphic style `{name}` no hi ha l’estil gràfic `{name}` @msg no graphic style `{n}` no hi ha l’estil gràfic `{n}` @msg no history to clear no hi ha historial per esborrar +@msg no history state {v} no hi ha cap estat de l’historial {v} @msg no interaction in progress no hi ha cap interacció en curs @msg no key object no hi ha objecte clau @msg no library `{id}` no hi ha la biblioteca `{id}` @@ -5824,6 +5852,11 @@ digits Malayalam Malaiàlam Keep data of its own with the document Desar dades pròpies amb el document Change how the objects its effect is applied to are drawn Canviar com es dibuixen els objectes als quals s’aplica el seu efecte Write the document as a file in its own format Escriure el document com un fitxer en un format propi + Show its progress, see Cancel and write log lines Mostrar el progrés, detectar la cancel·lació i escriure línies de registre +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permís `progress` necessita el nivell d’API de complements {PROGRESS_API}: indiqueu `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} el mòdul importa `env.{name}` amb un tipus diferent del de l’amfitrió: el de l’amfitrió és {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el mòdul importa `env.{name}`, que un complement només pot fer servir al nivell d’API de complements {PROGRESS_API} (`api` {PROGRESS_API}) i amb el permís `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el mòdul importa `{from}.{name}`, que l’amfitrió no ofereix: un complement només pot importar `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (nivell d’API de complements 2), i cap WASI, fitxer ni xarxa @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` és {_1}; aquest amfitrió admet els nivells d’API de complements de l’1 al {API_LEVEL} @msg `grant` must be a list of permission names `grant` ha de ser una llista de noms de permisos @msg `grant` names an unknown permission `{shown}` `grant` esmenta un permís desconegut `{shown}` @@ -6256,6 +6289,7 @@ breakText Glyphs Glifs Gap 5 Separació 5 @msg {_1}: they came in as the page's art {_1}: han entrat com a art de la pàgina +@msg warped text objects that came in as drawn art, not as editable type: {_1} objectes de text deformats que han entrat com a art, no com a text editable: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} els objectes que es dibuixaven diferent de la pàgina pròpia del fitxer han entrat com a art de la pàgina: {_1} @msg the JPEG 2000 file can't be read no es pot llegir el fitxer JPEG 2000 diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index eb3dd4da2..dc95fe117 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -456,6 +456,11 @@ Document Raster Effects Settings… Nastavení rastrových efektů dokumentu… Vector Effects Vektorové efekty 3D and Materials 3D a materiály + Plane Rovina + Extrude Vysunutí + Revolve Rotační těleso + Inflate Nafouknutí + Select objects, then pick an object type to give them a 3D effect. Vyberte objekty a pak zvolte typ objektu, abyste jim dali 3D efekt. Extrude & Bevel… Vysunutí a zkosení… Revolve… Rotační těleso… Inflate… Nafouknutí… @@ -819,6 +824,9 @@ Put back in the toolbar Vrátit do panelu nástrojů Float Panel Plovoucí panel Dock Panel Ukotvit panel + Dock to Top Ukotvit nahoru + Dock to Bottom Ukotvit dolů + Dock Control Bar Ukotvit ovládací panel Float Tool Group Plovoucí skupina nástrojů # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. @@ -1311,6 +1319,7 @@ lighting Light Světlo Rotate Tool Nástroj Otočení Rotate View Tool Nástroj Otočení zobrazení Trackpad Gesture to Rotate View Otáčet zobrazení gestem na trackpadu + Hold a Tool Shortcut to Use the Tool Temporarily Podržením klávesové zkratky nástroj dočasně použít Rotate View Otočení zobrazení Rounded Rectangle Tool Nástroj Zaoblený obdélník Scale Tool Nástroj Změna velikosti @@ -1636,6 +1645,7 @@ digits Malayalam Malajálamské Step Backward Krok zpět Step Forward Krok vpřed Clear History Vymazat historii + New Document from State Nový dokument ze stavu Set History Limit… Nastavit limit historie… Open a document to see its history. Otevřete dokument, abyste viděli jeho historii. # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2083,6 +2093,14 @@ digits Malayalam Malajálamské Location Umístění No images Žádné obrázky Relink to Folder… Znovu propojit ke složce… + Each Link Separately Každé propojení zvlášť + Selected Instances Vybrané instance + All Instances Všechny instance + Missing Links from This Folder Chybějící propojení z této složky +@msg scope {v} is not each|selected|all|missingInFolder scope {v} není each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each a paths patří k sobě: paths uvádí položky s ids a path místo ids, path a folder +@msg paths entry {i} needs path and ids (object ids no other entry has) položka {i} v paths potřebuje path a ids (ID objektů, které nemá žádná jiná položka) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder bere path: ve složce tohoto souboru se hledají ostatní chybějící propojení Show Missing Zobrazit chybějící Sort by Status Seřadit podle stavu Clear Overrides Vymazat přepsání @@ -2588,6 +2606,8 @@ digits Malayalam Malajálamské Show Cross Hairs Zobrazit nitkový kříž Show Video Safe Areas Zobrazit bezpečné oblasti videa Video Ruler Pixel Aspect Ratio: Poměr stran pixelu video pravítek: + Fade Region Outside Artboard Ztmavit oblast mimo pracovní plochu + Update While Dragging Aktualizovat při tažení Hide Video Rulers Skrýt video pravítka Client-side (.html) Na straně klienta (.html) Color Reduction: Redukce barev: @@ -3201,6 +3221,7 @@ theme Appearance Motiv Redefine Symbol Předefinovat symbol Remove Brush Stroke Odebrat tah štětcem Replace Symbol Nahradit symbol + Reset Transformation Obnovit transformaci Select All Instances Vybrat všechny instance Symbol Sprayer Rozprašovač symbolů Symbolism Tool Nástroj symbolů @@ -3228,6 +3249,9 @@ theme Appearance Motiv Convert Document Color Mode Převést barevný režim dokumentu Gamut Warning Upozornění na gamut Load Color Profile… Načíst barevný profil… + Load Character Styles… Načíst styly znaků… + Load Paragraph Styles… Načíst styly odstavců… + Load All Styles… Načíst všechny styly… Remove Color Profile… Odebrat barevný profil… Plates Desky Spot Color Přímá barva @@ -3673,6 +3697,8 @@ theme Appearance Motiv Real-time Drawing and Editing Kreslení a úpravy v reálném čase Render Threads (-1 = Automatic) Vlákna vykreslování (-1 = automaticky) Reset Preferences Obnovit předvolby + Search Preferences Hledat v předvolbách + No settings found Nenalezena žádná nastavení Scale Corners Měnit velikost rohů Scale Cursor Proportional to UI Měnit velikost kurzoru úměrně rozhraní Scale Strokes & Effects Měnit velikost tahů a efektů @@ -3693,6 +3719,7 @@ theme Appearance Motiv Snapping Tolerance Tolerance přichytávání Spacing Guides Vodítka rozestupů Invoke Distance Guides Vyvolat vodítka vzdálenosti + Snap to Last Location Přichytávat k poslednímu umístění Spanish Španělština Subdivisions Dělení Templates Folder Složka šablon @@ -3860,6 +3887,8 @@ axis Both Obě Center Střed Transform in Perspective Transformovat v perspektivě Grayscale Stupně šedi + High Color Mnoho barev + Low Color Málo barev Embed Vložit Star Hvězda Power Saving (integrated) Úsporný režim (integrovaná) @@ -4684,6 +4713,7 @@ imageTrace Outline Obrys @msg nothing to save: the document has no visible art není co uložit: dokument nemá žádnou viditelnou kresbu @msg nothing to undo není co vrátit @msg no history to clear není žádná historie k vymazání +@msg no history state {v} žádný stav historie {v} @msg nothing to warp není co pokřivit @msg nothing to wrap není co obtékat @msg object {0} cannot have children objekt {0} nemůže mít potomky @@ -5019,6 +5049,7 @@ imageTrace Outline Obrys @msg No linked files to look for Žádné propojené soubory k vyhledání @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} +@msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} @msg `corners` must be an array of anchor indices, not {v} `corners` musí být pole indexů kotevních bodů, ne {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: výsledek kroku {i} takovou hodnotu nemá (pro samotný znak $ napište $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: před tímto krokem není krok {i} (kroky se číslují od 0; pro samotný znak $ napište $$) @@ -5801,6 +5832,11 @@ axis Y Axis osa Y Keep data of its own with the document Ukládat vlastní data s dokumentem Change how the objects its effect is applied to are drawn Měnit, jak se kreslí objekty, na které je použit jeho efekt Write the document as a file in its own format Zapsat dokument jako soubor ve vlastním formátu + Show its progress, see Cancel and write log lines Zobrazit průběh, rozpoznat zrušení a zapisovat řádky protokolu +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} oprávnění `progress` vyžaduje úroveň API zásuvných modulů {PROGRESS_API}: uveďte `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modul importuje `env.{name}` s jiným typem, než má hostitel: typ hostitele je {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modul importuje `env.{name}`, který zásuvný modul smí používat jen na úrovni API zásuvných modulů {PROGRESS_API} (`api` {PROGRESS_API}) a s oprávněním `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modul importuje `{from}.{name}`, který hostitel nenabízí: zásuvný modul smí importovat jen `env.vc_progress`, `env.vc_cancelled` a `env.vc_log` (úroveň API zásuvných modulů 2), a žádné WASI, soubory ani síť @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` je {_1}; tento hostitel podporuje úrovně API zásuvných modulů 1 až {API_LEVEL} @msg `grant` must be a list of permission names `grant` musí být seznam názvů oprávnění @msg `grant` names an unknown permission `{shown}` `grant` uvádí neznámé oprávnění `{shown}` @@ -6272,6 +6308,7 @@ breakText Glyphs Glyfy Gap 5 Mezera 5 @msg {_1}: they came in as the page's art {_1}: byly načteny jako kresba ze stránky +@msg warped text objects that came in as drawn art, not as editable type: {_1} pokřivené textové objekty načtené jako kresba, ne jako upravitelný text: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekty, které se vykreslily jinak než vlastní stránka souboru, byly načteny jako kresba ze stránky: {_1} @msg You must choose a side pattern. Musíte zvolit boční vzorek. diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index a36911a35..4eb66ee63 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -414,6 +414,11 @@ Document Raster Effects Settings… Indstillinger for rastereffekter i dokumentet… Vector Effects Vektoreffekter 3D and Materials 3D og materialer + Plane Plan + Extrude Ekstrudér + Revolve Omdrej + Inflate Pust op + Select objects, then pick an object type to give them a 3D effect. Markér objekter, og vælg derefter en objekttype for at give dem en 3D-effekt. Extrude & Bevel… Ekstrudér og facet… Revolve… Omdrej… Inflate… Pust op… @@ -753,6 +758,9 @@ Put back in the toolbar Sæt tilbage på værktøjslinjen Float Panel Flydende panel Dock Panel Dok panel + Dock to Top Dok øverst + Dock to Bottom Dok nederst + Dock Control Bar Dok kontrollinjen Float Tool Group Flydende værktøjsgruppe Services Tjenester Hide VectorCraft Skjul VectorCraft @@ -1224,6 +1232,7 @@ lighting Light Lys Rotate Tool Roteringsværktøj Rotate View Tool Visningsroteringsværktøj Trackpad Gesture to Rotate View Rotér visning med bevægelse på pegefeltet + Hold a Tool Shortcut to Use the Tool Temporarily Hold et værktøjs genvejstast nede for at bruge værktøjet midlertidigt Rotate View Rotér visning Rounded Rectangle Tool Afrundet rektangelværktøj Scale Tool Skaleringsværktøj @@ -1546,6 +1555,7 @@ digits Malayalam Malayalam Step Backward Et trin tilbage Step Forward Et trin frem Clear History Ryd historik + New Document from State Nyt dokument fra tilstand Set History Limit… Angiv historikgrænse… Open a document to see its history. Åbn et dokument for at se dets historik. Abutting Stødende @@ -1889,6 +1899,14 @@ digits Malayalam Malayalam Location Placering No images Ingen billeder Relink to Folder… Genopret link til mappe… + Each Link Separately Hvert link for sig + Selected Instances Valgte forekomster + All Instances Alle forekomster + Missing Links from This Folder Manglende links fra denne mappe +@msg scope {v} is not each|selected|all|missingInFolder scope {v} er ikke each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each og paths hører sammen: paths angiver poster med ids og path i stedet for ids, path og folder +@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths skal have path og ids (objekt-id'er, som ingen anden post har) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tager path: der søges efter de andre manglende links i den fils mappe Show Missing Vis manglende Sort by Status Sortér efter status Clear Overrides Ryd tilsidesættelser @@ -2376,6 +2394,8 @@ digits Malayalam Malayalam Show Cross Hairs Vis trådkors Show Video Safe Areas Vis sikre videoområder Video Ruler Pixel Aspect Ratio: Pixelformat for videolinealer: + Fade Region Outside Artboard Ton området uden for kunstpladen ned + Update While Dragging Opdater under trækning Hide Video Rulers Skjul videolinealer Client-side (.html) Klientbaseret (.html) Color Reduction: Farvereduktion: @@ -2945,6 +2965,7 @@ theme Appearance Udseende Redefine Symbol Omdefinér symbol Remove Brush Stroke Fjern penselstrøg Replace Symbol Erstat symbol + Reset Transformation Nulstil transformation Select All Instances Markér alle forekomster Symbol Sprayer Symbolsprøjter Symbolism Tool Symbolværktøj @@ -2972,6 +2993,9 @@ theme Appearance Udseende Convert Document Color Mode Konvertér dokumentets farvetilstand Gamut Warning Gamut-advarsel Load Color Profile… Indlæs farveprofil… + Load Character Styles… Indlæs tegnstile… + Load Paragraph Styles… Indlæs afsnitsstile… + Load All Styles… Indlæs alle stile… Plates Plader Spot Color Spotfarve Add to Swatches Føj til farveprøver @@ -3401,6 +3425,8 @@ theme Appearance Udseende Real-time Drawing and Editing Tegning og redigering i realtid Render Threads (-1 = Automatic) Renderingstråde (-1 = automatisk) Reset Preferences Nulstil indstillinger + Search Preferences Søg i præferencer + No settings found Ingen indstillinger fundet Scale Corners Skalér hjørner Scale Cursor Proportional to UI Skalér markør proportionalt med brugergrænsefladen Scale Strokes & Effects Skalér konturer & effekter @@ -3421,6 +3447,7 @@ theme Appearance Udseende Snapping Tolerance Snap-tolerance Spacing Guides Afstandshjælpelinjer Invoke Distance Guides Aktivér hjælpelinjer for afstandsmåling + Snap to Last Location Fastgør til sidste placering Spanish Spansk Subdivisions Underinddelinger Templates Folder Skabelonmappe @@ -3588,6 +3615,8 @@ axis Both Begge Center Midt Transform in Perspective Transformer i perspektiv Grayscale Gråtoner + High Color Mange farver + Low Color Få farver Embed Integrér Star Stjerne Power Saving (integrated) Strømbesparende (integreret) @@ -4339,6 +4368,7 @@ imageTrace Outline Kontur @msg nothing to save: the document has no visible art intet at gemme: dokumentet har ingen synlig grafik @msg nothing to undo intet at fortryde @msg no history to clear ingen historik at rydde +@msg no history state {v} ingen historiktilstand {v} @msg nothing to warp intet at forvride @msg nothing to wrap intet at ombryde @msg object {0} cannot have children objektet {0} kan ikke have underobjekter @@ -4647,6 +4677,7 @@ imageTrace Outline Kontur @msg No linked files to look for Ingen linkede filer at lede efter @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` er trinreferencen {r}, som kun `run` og `command_batch` fortolker @msg `ids` must be an array of object ids, not {v} `ids` skal være et array af objekt-id'er, ikke {v} +@msg `colors` must be an array of colors, not {v} `colors` skal være et array af farver, ikke {v} @msg `corners` must be an array of anchor indices, not {v} `corners` skal være et array af ankerindekser, ikke {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet af trin {i} har ingen sådan værdi (skriv $$ for at få et bogstaveligt $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: der findes intet trin {i} før dette (trin nummereres fra 0; skriv $$ for at få et bogstaveligt $) @@ -5700,6 +5731,7 @@ axis Y Axis Y-akse Keep data of its own with the document Gemme sine egne data med dokumentet Change how the objects its effect is applied to are drawn Ændre, hvordan de objekter, dens effekt er anvendt på, tegnes Write the document as a file in its own format Skrive dokumentet som en fil i sit eget format + Show its progress, see Cancel and write log lines Vise sin fremdrift, se en annullering og skrive logrækker # Actions panel: action sets (#3712). New Set… Nyt sæt… New Set Nyt sæt @@ -6124,6 +6156,7 @@ breakText Glyphs Glyffer Gap 5 Mellemrum 5 @msg {_1}: they came in as the page's art {_1}: de blev importeret som sidens grafik +@msg warped text objects that came in as drawn art, not as editable type: {_1} vredne tekstobjekter, der blev importeret som grafik og ikke som redigerbar tekst: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekter, der blev tegnet anderledes end filens egen side, blev importeret som sidens grafik: {_1} @msg You must choose a side pattern. Du skal vælge et sidemønster. diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 720f94a16..6ae88f96e 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -59,6 +59,11 @@ 3 Color Job… 3-Farben-Auftrag… 30° Angles 30°-Winkel 3D and Materials 3D und Materialien + Plane Ebene + Extrude Extrudieren + Revolve Kreiseln + Inflate Aufblasen + Select objects, then pick an object type to give them a 3D effect. Wählen Sie Objekte aus und dann einen Objekttyp, um ihnen einen 3D-Effekt zu geben. 3D Ribbon 3D-Band 45° Angles 45°-Winkel 60° Angles 60°-Winkel @@ -259,6 +264,8 @@ font-technology Variable Variabel Show Cross Hairs Fadenkreuz einblenden Show Video Safe Areas Videosichere Bereiche einblenden Video Ruler Pixel Aspect Ratio: Pixel-Seitenverhältnis der Videolineale: + Fade Region Outside Artboard Bereich außerhalb der Zeichenfläche abblenden + Update While Dragging Beim Ziehen aktualisieren Hide Video Rulers Videolineale ausblenden Background Contents Hintergrundinhalt Background Contents: Hintergrundinhalt: @@ -1360,6 +1367,8 @@ file-filter Templates Vorlagen Grays Grautöne Grays and Neutrals Grautöne und neutrale Farben Grayscale Graustufen + High Color Viele Farben + Low Color Wenige Farben Grayscale images Graustufenbilder Greek & Cyrillic Griechisch und Kyrillisch Green Grün @@ -1432,6 +1441,7 @@ file-filter Templates Vorlagen Step Backward Schritt zurück Step Forward Schritt vorwärts Clear History Protokoll löschen + New Document from State Neues Dokument aus Protokollschritt Set History Limit… Protokollgrenze festlegen… History States Protokollschritte # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1825,6 +1835,9 @@ file-filter Templates Vorlagen Live Paint Selection Tool Interaktiv-malen-Auswahlwerkzeug Live Shape Properties Eigenschaften interaktiver Formen Load Color Profile… Farbprofil laden… + Load Character Styles… Zeichenformate laden… + Load Paragraph Styles… Absatzformate laden… + Load All Styles… Alle Formate laden… Remove Color Profile… Farbprofil entfernen… Load Image into Clipboard Bild in die Zwischenablage laden Load Metafile into Clipboard Metadatei in die Zwischenablage laden @@ -2652,6 +2665,14 @@ digits Malayalam Malayalam Relink Neu verknüpfen Relink Linked Files to the Document Verknüpfte Dateien neu mit dem Dokument verknüpfen Relink to Folder… Mit Ordner neu verknüpfen… + Each Link Separately Jede Verknüpfung einzeln + Selected Instances Ausgewählte Instanzen + All Instances Alle Instanzen + Missing Links from This Folder Fehlende Verknüpfungen aus diesem Ordner +@msg scope {v} is not each|selected|all|missingInFolder scope {v} ist nicht each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each und paths gehören zusammen: paths listet Einträge mit ids und path anstelle von ids, path und folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-Eintrag {i} braucht path und ids (Objekt-IDs, die kein anderer Eintrag hat) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nimmt path: Im Ordner dieser Datei wird nach den anderen fehlenden Verknüpfungen gesucht Relink… Neu verknüpfen… Find Linked Files Verknüpfte Dateien suchen Find Linked Files in Folder… Verknüpfte Dateien in Ordner suchen… @@ -2696,6 +2717,7 @@ digits Malayalam Malayalam Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersetzen Sie sie durch eine andere Datei oder ignorieren Sie sie: Ihre Bilder zeigen weiterhin die mit dem Dokument gespeicherte Vorschau. Replace Spine Achse ersetzen Replace Symbol Symbol ersetzen + Reset Transformation Transformation zurücksetzen Replace With Font Durch Schrift ersetzen Replace With Font From Ersetzende Schrift aus System System @@ -2712,6 +2734,8 @@ digits Malayalam Malayalam Reset OpenType Features OpenType-Funktionen zurücksetzen Reset Panel Bedienfeld zurücksetzen Reset Preferences Voreinstellungen zurücksetzen + Search Preferences Voreinstellungen durchsuchen + No settings found Keine Einstellungen gefunden Reset Profiles Profile zurücksetzen Rotate View Ansicht drehen Reset Rotate View Ansichtsdrehung zurücksetzen @@ -2767,6 +2791,7 @@ digits Malayalam Malayalam Rotate Tool Drehen-Werkzeug Rotate View Tool Ansichtdrehung-Werkzeug Trackpad Gesture to Rotate View Ansicht mit Trackpad-Geste drehen + Hold a Tool Shortcut to Use the Tool Temporarily Werkzeug-Tastaturbefehl gedrückt halten, um das Werkzeug vorübergehend zu verwenden Rotate… Drehen… Rotation Drehung Roughen… Aufrauen… @@ -3209,6 +3234,7 @@ effect Points Punkte Spaces Leerzeichen Spacing Guides Abstandshilfslinien Invoke Distance Guides Distanzhilfslinien aufrufen + Snap to Last Location An letzter Position ausrichten Spacing: Abstand: Spanish Spanisch Specified Distance Festgelegter Abstand @@ -3846,6 +3872,9 @@ theme Appearance Erscheinungsbild Put back in the toolbar Zurück in die Werkzeugleiste setzen Float Panel Bedienfeld ablösen Dock Panel Bedienfeld andocken + Dock to Top Oben andocken + Dock to Bottom Unten andocken + Dock Control Bar Steuerungsleiste andocken Drag to float the toolbar Ziehen, um die Werkzeugleiste abzulösen Drag to the window's left edge to dock Zum Andocken an den linken Fensterrand ziehen Float Tool Group Werkzeuggruppe ablösen @@ -4294,6 +4323,7 @@ theme Appearance Erscheinungsbild @msg nothing to save: the document has no visible art nichts zu speichern: Das Dokument enthält keine sichtbare Grafik @msg nothing to undo nichts rückgängig zu machen @msg no history to clear kein Protokoll zu löschen +@msg no history state {v} kein Protokollschritt {v} @msg nothing to warp nichts zu verkrümmen @msg nothing to wrap nichts zu umfließen @msg object {0} cannot have children Objekt {0} kann keine untergeordneten Objekte haben @@ -4676,6 +4706,7 @@ theme Appearance Erscheinungsbild ICF Box ICF-Rahmen @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` ist der Schrittverweis {r}, den nur `run` und `command_batch` auflösen @msg `ids` must be an array of object ids, not {v} `ids` muss ein Array von Objekt-IDs sein, nicht {v} +@msg `colors` must be an array of colors, not {v} `colors` muss ein Array von Farben sein, nicht {v} @msg `corners` must be an array of anchor indices, not {v} `corners` muss ein Array von Ankerpunkt-Indizes sein, nicht {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: Das Ergebnis von Schritt {i} hat keinen solchen Wert (schreiben Sie $$ für ein literales $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: Vor diesem Schritt gibt es keinen Schritt {i} (Schritte zählen ab 0; schreiben Sie $$ für ein literales $) @@ -5383,6 +5414,11 @@ axis Y Axis Y-Achse Keep data of its own with the document Eigene Daten mit dem Dokument speichern Change how the objects its effect is applied to are drawn Ändern, wie die Objekte gezeichnet werden, auf die sein Effekt angewendet ist Write the document as a file in its own format Das Dokument als Datei in einem eigenen Format schreiben + Show its progress, see Cancel and write log lines Den Fortschritt anzeigen, ein Abbrechen erkennen und Protokollzeilen schreiben +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} die Berechtigung `progress` braucht die Plug-in-API-Stufe {PROGRESS_API}: Geben Sie `api` {PROGRESS_API} an +@msg the module imports `env.{name}` with another type than the host's: it is {signature} das Modul importiert `env.{name}` mit einem anderen Typ als dem des Hosts: Der Typ des Hosts ist {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` das Modul importiert `env.{name}`, das ein Plug-in nur auf der Plug-in-API-Stufe {PROGRESS_API} (`api` {PROGRESS_API}) und mit der Berechtigung `progress` verwenden darf +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network das Modul importiert `{from}.{name}`, das der Host nicht anbietet: Ein Plug-in darf nur `env.vc_progress`, `env.vc_cancelled` und `env.vc_log` importieren (Plug-in-API-Stufe 2), aber kein WASI, keine Dateien und kein Netzwerk @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ist {_1}; dieser Host unterstützt die Plug-in-API-Stufen 1 bis {API_LEVEL} @msg `grant` must be a list of permission names `grant` muss eine Liste von Berechtigungsnamen sein @msg `grant` names an unknown permission `{shown}` `grant` nennt eine unbekannte Berechtigung `{shown}` @@ -5858,6 +5894,7 @@ breakText Glyphs Glyphen Gap 5 Lücke 5 @msg {_1}: they came in as the page's art {_1}: sie wurden als Seitengrafik importiert +@msg warped text objects that came in as drawn art, not as editable type: {_1} verkrümmte Textobjekte, die als Grafik und nicht als bearbeitbarer Text importiert wurden: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} Objekte, die anders als die eigene Seite der Datei gezeichnet wurden, wurden als Seitengrafik importiert: {_1} @msg You must choose a side pattern. Sie müssen ein Seitenmuster auswählen. diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 22a6511d3..5c3f193c5 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -66,6 +66,11 @@ 3 Color Job… Εργασία τριών χρωμάτων… 30° Angles Γωνίες 30° 3D and Materials 3D και υλικά + Plane Επίπεδο + Extrude Εξώθηση + Revolve Περιστροφή γύρω από άξονα + Inflate Φούσκωμα + Select objects, then pick an object type to give them a 3D effect. Επιλέξτε αντικείμενα και, στη συνέχεια, έναν τύπο αντικειμένου για να τους δώσετε εφέ 3D. 3D Ribbon Τρισδιάστατη κορδέλα 45° Angles Γωνίες 45° 60° Angles Γωνίες 60° @@ -262,6 +267,8 @@ font-technology Variable Μεταβλητές Show Cross Hairs Εμφάνιση σταυρονήματος Show Video Safe Areas Εμφάνιση ασφαλών περιοχών βίντεο Video Ruler Pixel Aspect Ratio: Αναλογία διαστάσεων pixel χαράκων βίντεο: + Fade Region Outside Artboard Σκίαση περιοχής εκτός πίνακα σχεδίασης + Update While Dragging Ενημέρωση κατά τη μεταφορά Hide Video Rulers Απόκρυψη χαράκων βίντεο Background Contents Περιεχόμενα φόντου Background Contents: Περιεχόμενο φόντου: @@ -1299,6 +1306,8 @@ font-technology Variable Μεταβλητές Grays Γκρι τόνοι Grays and Neutrals Γκρι και ουδέτεροι τόνοι Grayscale Κλίμακα του γκρι + High Color Πολλά χρώματα + Low Color Λίγα χρώματα Grayscale images Εικόνες σε κλίμακα του γκρι Greek & Cyrillic Ελληνικά και κυριλλικά Green Πράσινο @@ -1371,6 +1380,7 @@ font-technology Variable Μεταβλητές Step Backward Ένα βήμα πίσω Step Forward Ένα βήμα εμπρός Clear History Εκκαθάριση ιστορικού + New Document from State Νέο έγγραφο από κατάσταση Set History Limit… Ορισμός ορίου ιστορικού… History States Καταστάσεις ιστορικού # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1871,6 +1881,8 @@ font-technology Variable Μεταβλητές Reselect Επαναφορά προηγούμενης επιλογής Reset Επαναφορά Reset Preferences Επαναφορά προτιμήσεων + Search Preferences Αναζήτηση στις προτιμήσεις + No settings found Δεν βρέθηκαν ρυθμίσεις Reset to Default Επαναφορά προεπιλογής Resolution: Ανάλυση: Restore Επαναφορά @@ -1883,6 +1895,7 @@ font-technology Variable Μεταβλητές Rotate Περιστροφή Rotate View Tool Εργαλείο περιστροφής προβολής (Rotate View Tool) Trackpad Gesture to Rotate View Περιστροφή προβολής με χειρονομία στην επιφάνεια αφής + Hold a Tool Shortcut to Use the Tool Temporarily Προσωρινή χρήση εργαλείου κρατώντας πατημένη τη συντόμευσή του Rotate… Περιστροφή… Rotation Περιστροφή Rows: Γραμμές: @@ -2136,6 +2149,9 @@ axis Vertical Κατακόρυφα axis Both Και τα δύο Float Panel Αιωρούμενο πάνελ Dock Panel Αγκύρωση πάνελ + Dock to Top Αγκύρωση επάνω + Dock to Bottom Αγκύρωση κάτω + Dock Control Bar Αγκύρωση γραμμής ελέγχου @msg no library `{id}` Δεν υπάρχει βιβλιοθήκη με αναγνωριστικό `{id}` @msg select some type first Επιλέξτε πρώτα κάποιο κείμενο @msg kerning must be a number, "auto", or "optical" Η διαγραμμάτωση πρέπει να είναι αριθμός, «auto» ή «optical» @@ -2636,6 +2652,9 @@ theme Appearance Εμφάνιση Live Paint Selection Tool Επιλογή δυναμικής βαφής (Live Paint Selection Tool) Live Shape Properties Ιδιότητες δυναμικού σχήματος Load Color Profile… Φόρτωση προφίλ χρώματος… + Load Character Styles… Φόρτωση στυλ χαρακτήρων… + Load Paragraph Styles… Φόρτωση στυλ παραγράφων… + Load All Styles… Φόρτωση όλων των στυλ… Remove Color Profile… Αφαίρεση προφίλ χρώματος… Load Image into Clipboard Φόρτωση εικόνας στο πρόχειρο Load Metafile into Clipboard Φόρτωση μετααρχείου στο πρόχειρο @@ -3256,6 +3275,14 @@ digits Malayalam Μαλαγιάλαμ Relink Επανασύνδεση Relink Linked Files to the Document Επανασύνδεση συνδεδεμένων αρχείων με το έγγραφο Relink to Folder… Επανασύνδεση σε φάκελο… + Each Link Separately Κάθε σύνδεση χωριστά + Selected Instances Επιλεγμένες παρουσίες + All Instances Όλες οι παρουσίες + Missing Links from This Folder Συνδέσεις που λείπουν από αυτόν τον φάκελο +@msg scope {v} is not each|selected|all|missingInFolder το scope {v} δεν είναι each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder τα scope each και paths πάνε μαζί: το paths απαριθμεί καταχωρίσεις με ids και path, αντί για ids, path και folder +@msg paths entry {i} needs path and ids (object ids no other entry has) η καταχώριση {i} του paths χρειάζεται path και ids (αναγνωριστικά αντικειμένων που δεν έχει άλλη καταχώριση) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links το scope missingInFolder δέχεται path: στον φάκελο αυτού του αρχείου αναζητούνται οι άλλες συνδέσεις που λείπουν Relink… Επανασύνδεση… Find Linked Files Εύρεση συνδεδεμένων αρχείων Find Linked Files in Folder… Εύρεση συνδεδεμένων αρχείων σε φάκελο… @@ -3295,6 +3322,7 @@ digits Malayalam Μαλαγιάλαμ Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Αντικαταστήστε το με άλλο αρχείο ή αγνοήστε το: οι εικόνες του θα συνεχίσουν να εμφανίζουν την προεπισκόπηση που αποθηκεύτηκε στο έγγραφο. Replace Spine Αντικατάσταση κεντρικής καμπύλης Replace Symbol Αντικατάσταση συμβόλου + Reset Transformation Επαναφορά μετασχηματισμού Replace With Font Αντικατάσταση με γραμματοσειρά Replace With Font From Αντικατάσταση με γραμματοσειρά από System Σύστημα @@ -3632,6 +3660,7 @@ digits Malayalam Μαλαγιάλαμ Spaces Διαστήματα Spacing Guides Οδηγοί αποστάσεων Invoke Distance Guides Εμφάνιση οδηγών μέτρησης απόστασης + Snap to Last Location Προσκόλληση στην τελευταία θέση Spanish Ισπανικά Specified Distance Καθορισμένη απόσταση Specified Steps Καθορισμένα βήματα @@ -4333,6 +4362,7 @@ digits Malayalam Μαλαγιάλαμ @msg nothing to save: the document has no visible art δεν υπάρχει τίποτα για αποθήκευση: το έγγραφο δεν έχει ορατό σχέδιο @msg nothing to undo δεν υπάρχει τίποτα για αναίρεση @msg no history to clear δεν υπάρχει ιστορικό για εκκαθάριση +@msg no history state {v} δεν υπάρχει κατάσταση ιστορικού {v} @msg nothing to warp δεν υπάρχει τίποτα για στρέβλωση @msg nothing to wrap δεν υπάρχει τίποτα για αναδίπλωση @msg object {0} cannot have children το αντικείμενο {0} δεν μπορεί να έχει θυγατρικά στοιχεία @@ -4700,6 +4730,7 @@ digits Malayalam Μαλαγιάλαμ ICF Box Πλαίσιο ICF @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve το `id` είναι η αναφορά βήματος {r}, την οποία αναλύουν μόνο τα `run` και `command_batch` @msg `ids` must be an array of object ids, not {v} το `ids` πρέπει να είναι πίνακας αναγνωριστικών αντικειμένων, όχι {v} +@msg `colors` must be an array of colors, not {v} το `colors` πρέπει να είναι πίνακας χρωμάτων, όχι {v} @msg `corners` must be an array of anchor indices, not {v} το `corners` πρέπει να είναι πίνακας δεικτών αγκύρωσης, όχι {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: το αποτέλεσμα του βήματος {i} δεν έχει τέτοια τιμή (γράψτε $$ για κυριολεκτικό $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: δεν υπάρχει βήμα {i} πριν από αυτό (η αρίθμηση αρχίζει από 0· γράψτε $$ για κυριολεκτικό $) @@ -5752,6 +5783,11 @@ axis Y Axis Άξονας Y Keep data of its own with the document Αποθήκευση δικών του δεδομένων μαζί με το έγγραφο Change how the objects its effect is applied to are drawn Αλλαγή του τρόπου σχεδίασης των αντικειμένων στα οποία εφαρμόζεται το εφέ του Write the document as a file in its own format Εγγραφή του εγγράφου ως αρχείου σε δική του μορφή + Show its progress, see Cancel and write log lines Εμφάνιση της προόδου, αναγνώριση της ακύρωσης και εγγραφή γραμμών καταγραφής +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} το δικαίωμα `progress` χρειάζεται το επίπεδο API προσθέτων {PROGRESS_API}: δώστε `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} η λειτουργική μονάδα εισάγει το `env.{name}` με άλλον τύπο από αυτόν του κεντρικού υπολογιστή: ο τύπος του είναι {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` η λειτουργική μονάδα εισάγει το `env.{name}`, το οποίο ένα πρόσθετο μπορεί να χρησιμοποιεί μόνο στο επίπεδο API προσθέτων {PROGRESS_API} (`api` {PROGRESS_API}) και με το δικαίωμα `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network η λειτουργική μονάδα εισάγει το `{from}.{name}`, το οποίο δεν προσφέρει ο κεντρικός υπολογιστής: ένα πρόσθετο μπορεί να εισάγει μόνο τα `env.vc_progress`, `env.vc_cancelled` και `env.vc_log` (επίπεδο API προσθέτων 2) και κανένα WASI, αρχεία ή δίκτυο @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} το `api` είναι {_1}· αυτός ο κεντρικός υπολογιστής υποστηρίζει τα επίπεδα API προσθέτων 1 έως {API_LEVEL} @msg `grant` must be a list of permission names το `grant` πρέπει να είναι λίστα ονομάτων δικαιωμάτων @msg `grant` names an unknown permission `{shown}` το `grant` αναφέρει άγνωστο δικαίωμα `{shown}` @@ -6224,6 +6260,7 @@ breakText Glyphs Γλύφοι Gap 5 Διάκενο 5 @msg {_1}: they came in as the page's art {_1}: εισήχθησαν ως γραφικά της σελίδας +@msg warped text objects that came in as drawn art, not as editable type: {_1} παραμορφωμένα αντικείμενα κειμένου που εισήχθησαν ως γραφικά, όχι ως επεξεργάσιμο κείμενο: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} τα αντικείμενα που σχεδιάστηκαν διαφορετικά από την ίδια τη σελίδα του αρχείου εισήχθησαν ως γραφικά της σελίδας: {_1} @msg You must choose a side pattern. Πρέπει να επιλέξετε πλευρικό μοτίβο. diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index d574f8efa..99c7c5d0c 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -84,6 +84,11 @@ 3 Color Job… Trabajo a 3 colores… 30° Angles Ángulos de 30° 3D and Materials 3D y materiales + Plane Plano + Extrude Extrusión + Revolve Revolución + Inflate Inflar + Select objects, then pick an object type to give them a 3D effect. Seleccione objetos y luego elija un tipo de objeto para darles un efecto 3D. 3D Ribbon Cinta 3D 45° Angles Ángulos de 45° 60° Angles Ángulos de 60° @@ -284,6 +289,8 @@ font-technology Variable Variables Show Cross Hairs Mostrar líneas en cruz Show Video Safe Areas Mostrar áreas seguras de video Video Ruler Pixel Aspect Ratio: Proporción de píxel de las reglas de video: + Fade Region Outside Artboard Atenuar región fuera de la mesa de trabajo + Update While Dragging Actualizar al arrastrar Hide Video Rulers Ocultar reglas de video Background Contents Contenido de fondo Background Contents: Contenido de fondo: @@ -1378,6 +1385,8 @@ font-technology Variable Variables Grays Grises Grays and Neutrals Grises y neutros Grayscale Escala de grises + High Color Muchos colores + Low Color Pocos colores Grayscale images Imágenes en escala de grises Greek & Cyrillic Griego y cirílico Green Verde @@ -1451,6 +1460,7 @@ font-technology Variable Variables Step Backward Paso atrás Step Forward Paso adelante Clear History Borrar historial + New Document from State Nuevo documento desde estado Set History Limit… Definir límite de historial… History States Estados de historial # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1828,6 +1838,9 @@ font-technology Variable Variables Live Paint Selection Tool Herramienta Selección de pintura interactiva Live Shape Properties Propiedades de forma dinámica Load Color Profile… Cargar perfil de color… + Load Character Styles… Cargar estilos de carácter… + Load Paragraph Styles… Cargar estilos de párrafo… + Load All Styles… Cargar todos los estilos… Remove Color Profile… Quitar perfil de color… Load Image into Clipboard Cargar imagen en el portapapeles Load Metafile into Clipboard Cargar metarchivo en el portapapeles @@ -2695,6 +2708,14 @@ digits Malayalam Malayálam Relink Volver a vincular Relink Linked Files to the Document Volver a vincular los archivos vinculados al documento Relink to Folder… Volver a vincular a carpeta… + Each Link Separately Cada vínculo por separado + Selected Instances Instancias seleccionadas + All Instances Todas las instancias + Missing Links from This Folder Vínculos que faltan de esta carpeta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} no es each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each y paths van juntos: paths enumera entradas con ids y path, en lugar de ids, path y folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la entrada {i} de paths necesita path e ids (id. de objeto que ninguna otra entrada tenga) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder toma path: en la carpeta de ese archivo se buscan los demás vínculos que faltan Relink… Volver a vincular… Find Linked Files Buscar archivos vinculados Find Linked Files in Folder… Buscar archivos vinculados en una carpeta… @@ -2739,6 +2760,7 @@ digits Malayalam Malayálam Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Reemplázalo con otro archivo o ignóralo: sus imágenes seguirán mostrando la vista previa guardada con el documento. Replace Spine Reemplazar eje Replace Symbol Reemplazar símbolo + Reset Transformation Restablecer transformación Replace With Font Reemplazar con fuente Replace With Font From Reemplazar con fuente de System Sistema @@ -2755,6 +2777,8 @@ digits Malayalam Malayálam Reset OpenType Features Restablecer funciones OpenType Reset Panel Restablecer panel Reset Preferences Restablecer preferencias + Search Preferences Buscar en preferencias + No settings found No se encontraron ajustes Reset Profiles Restablecer perfiles Reset Rotate View Restablecer rotación de vista Rotate View to Selection Rotar vista según la selección @@ -2809,6 +2833,7 @@ digits Malayalam Malayálam Rotate Tool Herramienta Rotar Rotate View Tool Herramienta Rotar vista Trackpad Gesture to Rotate View Rotar vista con un gesto del trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Mantener pulsado el atajo de una herramienta para usarla temporalmente Rotate… Rotar… Rotation Rotación Roughen… Hacer áspero… @@ -3248,6 +3273,7 @@ digits Malayalam Malayálam Spaces Espacios Spacing Guides Guías de espaciado Invoke Distance Guides Invocar guías de distancia + Snap to Last Location Ajustar a última ubicación Spacing: Espaciado: Spanish Español Specified Distance Distancia especificada @@ -3931,6 +3957,9 @@ axis Both Ambos Put back in the toolbar Devolver a la barra de herramientas Float Panel Hacer flotar el panel Dock Panel Anclar panel + Dock to Top Anclar arriba + Dock to Bottom Anclar abajo + Dock Control Bar Anclar barra de control Drag to float the toolbar Arrastrar para hacer flotar la barra de herramientas Drag to the window's left edge to dock Arrastrar al borde izquierdo de la ventana para anclar Float Tool Group Hacer flotar el grupo de herramientas @@ -4378,6 +4407,7 @@ axis Both Ambos @msg nothing to save: the document has no visible art no hay nada que guardar: el documento no tiene ilustración visible @msg nothing to undo no hay nada que deshacer @msg no history to clear no hay historial que borrar +@msg no history state {v} no hay ningún estado del historial {v} @msg nothing to warp no hay nada que deformar @msg nothing to wrap no hay nada que envolver @msg object {0} cannot have children el objeto {0} no puede tener hijos @@ -4739,6 +4769,7 @@ axis Both Ambos Perspective Distort Distorsión de perspectiva @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` es la referencia a un paso {r}, que solo `run` y `command_batch` resuelven @msg `ids` must be an array of object ids, not {v} `ids` debe ser una lista de identificadores de objeto, no {v} +@msg `colors` must be an array of colors, not {v} `colors` debe ser una lista de colores, no {v} @msg `corners` must be an array of anchor indices, not {v} `corners` debe ser una lista de índices de puntos de ancla, no {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: el resultado del paso {i} no tiene ese valor (escriba $$ para un $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: no hay ningún paso {i} antes de este (los pasos se cuentan desde 0; escriba $$ para un $ literal) @@ -5742,6 +5773,11 @@ axis Y Axis Eje Y Keep data of its own with the document Guardar datos propios con el documento Change how the objects its effect is applied to are drawn Cambiar cómo se dibujan los objetos a los que se aplica su efecto Write the document as a file in its own format Escribir el documento como un archivo en su propio formato + Show its progress, see Cancel and write log lines Mostrar su progreso, detectar la cancelación y escribir líneas de registro +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} el permiso `progress` necesita el nivel de API de plugins {PROGRESS_API}: indique `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} el módulo importa `env.{name}` con un tipo distinto del del host: el del host es {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` el módulo importa `env.{name}`, que un plugin solo puede usar en el nivel de API de plugins {PROGRESS_API} (`api` {PROGRESS_API}) y con el permiso `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network el módulo importa `{from}.{name}`, que el host no ofrece: un plugin solo puede importar `env.vc_progress`, `env.vc_cancelled` y `env.vc_log` (nivel de API de plugins 2), y ningún WASI, archivo ni red @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` es {_1}; este host admite los niveles de API de plugins de 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` debe ser una lista de nombres de permisos @msg `grant` names an unknown permission `{shown}` `grant` nombra un permiso desconocido `{shown}` @@ -6214,6 +6250,7 @@ breakText Glyphs Glifos Gap 5 Hueco 5 @msg {_1}: they came in as the page's art {_1}: se importaron como el arte de la página +@msg warped text objects that came in as drawn art, not as editable type: {_1} objetos de texto deformados que se importaron como arte, no como texto editable: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} los objetos que se dibujaban de forma distinta a la página del propio archivo se importaron como el arte de la página: {_1} @msg You must choose a side pattern. Debe elegir un motivo lateral. diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 399c8e5fa..53103e483 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -453,6 +453,11 @@ Document Raster Effects Settings… Asiakirjan rasteritehosteiden asetukset… Vector Effects Vektoritehosteet 3D and Materials 3D ja materiaalit + Plane Taso + Extrude Pursota + Revolve Pyöräytä + Inflate Täytä ilmalla + Select objects, then pick an object type to give them a 3D effect. Valitse objektit ja valitse sitten objektityyppi, niin niille tulee 3D-tehoste. Extrude & Bevel… Pursota ja viistä… Revolve… Pyöräytä… Inflate… Täytä ilmalla… @@ -807,6 +812,9 @@ Put back in the toolbar Palauta työkaluriviin Float Panel Kelluta paneeli Dock Panel Telakoi paneeli + Dock to Top Telakoi ylös + Dock to Bottom Telakoi alas + Dock Control Bar Telakoi ohjauspalkki Float Tool Group Kelluta työkaluryhmä Services Palvelut Hide VectorCraft Piilota VectorCraft @@ -1287,6 +1295,7 @@ lighting Light Valo Rotate Tool Kiertotyökalu Rotate View Tool Näkymän kiertotyökalu Trackpad Gesture to Rotate View Kierrä näkymää ohjauslevyn eleellä + Hold a Tool Shortcut to Use the Tool Temporarily Käytä työkalua tilapäisesti pitämällä sen pikanäppäintä painettuna Rotate View Kierrä näkymää Rounded Rectangle Tool Pyöristetyn suorakulmion työkalu Scale Tool Skaalaustyökalu @@ -1609,6 +1618,7 @@ digits Malayalam Malajalam Step Backward Askel taaksepäin Step Forward Askel eteenpäin Clear History Tyhjennä historia + New Document from State Uusi asiakirja tilasta Set History Limit… Aseta historian raja… Open a document to see its history. Avaa asiakirja, niin näet sen historian. # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2044,6 +2054,14 @@ digits Malayalam Malajalam Location Sijainti No images Ei kuvia Relink to Folder… Linkitä uudelleen kansioon… + Each Link Separately Jokainen linkki erikseen + Selected Instances Valitut esiintymät + All Instances Kaikki esiintymät + Missing Links from This Folder Puuttuvat linkit tästä kansiosta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} ei ole each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each ja paths kuuluvat yhteen: paths luettelee merkinnät, joissa on ids ja path, ids:n, path:n ja folder:n sijaan +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-merkintä {i} tarvitsee path- ja ids-arvot (objektitunnukset, joita millään muulla merkinnällä ei ole) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder ottaa path-arvon: muita puuttuvia linkkejä haetaan tämän tiedoston kansiosta Show Missing Näytä puuttuvat Sort by Status Lajittele tilan mukaan Clear Overrides Tyhjennä ohitukset @@ -2532,6 +2550,8 @@ digits Malayalam Malajalam Show Cross Hairs Näytä hiusristikko Show Video Safe Areas Näytä videon turva-alueet Video Ruler Pixel Aspect Ratio: Videoviivainten kuvapisteen kuvasuhde: + Fade Region Outside Artboard Himmennä piirtoalustan ulkopuolinen alue + Update While Dragging Päivitä vedettäessä Hide Video Rulers Piilota videoviivaimet Client-side (.html) Asiakaspuoli (.html) Color Reduction: Värien vähennys: @@ -3140,6 +3160,7 @@ theme Appearance Ulkoasu Redefine Symbol Määritä symboli uudelleen Remove Brush Stroke Poista sivellinviiva Replace Symbol Korvaa symboli + Reset Transformation Palauta muunnos Select All Instances Valitse kaikki ilmentymät Symbol Sprayer Symbolisuihkutin Symbolism Tool Symbolityökalu @@ -3167,6 +3188,9 @@ theme Appearance Ulkoasu Convert Document Color Mode Muunna asiakirjan väritila Gamut Warning Väriavaruusvaroitus Load Color Profile… Lataa väriprofiili… + Load Character Styles… Lataa merkkityylit… + Load Paragraph Styles… Lataa kappaletyylit… + Load All Styles… Lataa kaikki tyylit… Remove Color Profile… Poista väriprofiili… Plates Erottelut Spot Color Spottiväri @@ -3597,6 +3621,8 @@ theme Appearance Ulkoasu Real-time Drawing and Editing Reaaliaikainen piirto ja muokkaus Render Threads (-1 = Automatic) Renderöintisäikeet (-1 = automaattinen) Reset Preferences Palauta asetukset + Search Preferences Hae asetuksista + No settings found Asetuksia ei löytynyt Scale Corners Skaalaa kulmat Scale Cursor Proportional to UI Skaalaa kohdistin suhteessa käyttöliittymään Scale Strokes & Effects Skaalaa viivat ja tehosteet @@ -3617,6 +3643,7 @@ theme Appearance Ulkoasu Snapping Tolerance Tartuntatoleranssi Spacing Guides Välistysapuviivat Invoke Distance Guides Käytä etäisyysapuviivoja + Snap to Last Location Kohdista viimeiseen sijaintiin Spanish Espanja Subdivisions Jaot Templates Folder Mallipohjakansio @@ -3784,6 +3811,8 @@ axis Both Molemmat Center Keskitä Transform in Perspective Muunna perspektiivissä Grayscale Harmaasävy + High Color Paljon värejä + Low Color Vähän värejä Embed Upota Star Tähti Power Saving (integrated) Virransäästö (integroitu) @@ -4592,6 +4621,7 @@ imageTrace Outline Ääriviiva @msg nothing to save: the document has no visible art ei tallennettavaa: asiakirjassa ei ole näkyvää kuvitusta @msg nothing to undo ei kumottavaa @msg no history to clear ei tyhjennettävää historiaa +@msg no history state {v} ei historiatilaa {v} @msg nothing to warp ei taivutettavaa @msg nothing to wrap ei rivitettävää @msg object {0} cannot have children objektilla {0} ei voi olla alikohteita @@ -4921,6 +4951,7 @@ imageTrace Outline Ääriviiva @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} +@msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} @msg `corners` must be an array of anchor indices, not {v} `corners` on oltava ankkuri-indeksien taulukko, ei {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: vaiheen {i} tuloksessa ei ole tällaista arvoa (kirjoita $$, jos tarkoitat merkkiä $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ennen tätä ei ole vaihetta {i} (vaiheet numeroidaan 0:sta; kirjoita $$, jos tarkoitat merkkiä $) @@ -5831,6 +5862,11 @@ axis Y Axis Y-akseli Keep data of its own with the document Tallentaa omia tietojaan asiakirjaan Change how the objects its effect is applied to are drawn Muuttaa sitä, miten objektit, joihin sen tehostetta on käytetty, piirretään Write the document as a file in its own format Kirjoittaa asiakirjan tiedostoksi omassa muodossaan + Show its progress, see Cancel and write log lines Näyttää edistymisensä, havaita peruutuksen ja kirjoittaa lokirivejä +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} käyttöoikeus `progress` vaatii laajennus-API:n tason {PROGRESS_API}: anna `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduuli tuo kohteen `env.{name}` eri tyyppisenä kuin isäntä: isännän tyyppi on {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduuli tuo kohteen `env.{name}`, jota laajennus saa käyttää vain laajennus-API:n tasolla {PROGRESS_API} (`api` {PROGRESS_API}) ja käyttöoikeudella `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduuli tuo kohteen `{from}.{name}`, jota isäntä ei tarjoa: laajennus saa tuoda vain kohteet `env.vc_progress`, `env.vc_cancelled` ja `env.vc_log` (laajennus-API:n taso 2) eikä mitään WASIa, tiedostoja tai verkkoa @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` on {_1}; tämä isäntä tukee laajennus-API:n tasoja 1 ja {API_LEVEL} välillä @msg `grant` must be a list of permission names `grant` on oltava luettelo käyttöoikeuksien nimistä @msg `grant` names an unknown permission `{shown}` `grant` nimeää tuntemattoman käyttöoikeuden `{shown}` @@ -6302,6 +6338,7 @@ breakText Glyphs Merkit Gap 5 Väli 5 @msg {_1}: they came in as the page's art {_1}: ne tuotiin sivun piirroksena +@msg warped text objects that came in as drawn art, not as editable type: {_1} vääristetyt tekstiobjektit, jotka tuotiin piirroksena eikä muokattavana tekstinä: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objektit, jotka piirtyivät eri tavoin kuin tiedoston oma sivu, tuotiin sivun piirroksena: {_1} @msg You must choose a side pattern. Valitse sivukuvio. diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 78215fec9..717a8a3f6 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -85,6 +85,11 @@ 3 Color Job… Travail en 3 couleurs… 30° Angles Angles de 30° 3D and Materials 3D et matières + Plane Plan + Extrude Extrusion + Revolve Révolution + Inflate Gonfler + Select objects, then pick an object type to give them a 3D effect. Sélectionnez des objets, puis choisissez un type d’objet pour leur donner un effet 3D. 3D Ribbon Ruban 3D 45° Angles Angles de 45° 60° Angles Angles de 60° @@ -285,6 +290,8 @@ font-technology Variable Variables Show Cross Hairs Afficher le réticule Show Video Safe Areas Afficher les zones de sécurité vidéo Video Ruler Pixel Aspect Ratio: Format des pixels des règles vidéo : + Fade Region Outside Artboard Assombrir la zone hors du plan de travail + Update While Dragging Mettre à jour pendant le déplacement Hide Video Rulers Masquer les règles vidéo Background Contents Contenu de l’arrière-plan Background Contents: Contenu de l’arrière-plan : @@ -1372,6 +1379,8 @@ font-technology Variable Variables Grays Gris Grays and Neutrals Gris et neutres Grayscale Niveaux de gris + High Color Beaucoup de couleurs + Low Color Peu de couleurs Grayscale images Images en niveaux de gris Greek & Cyrillic Grec et cyrillique Green Vert @@ -1445,6 +1454,7 @@ font-technology Variable Variables Step Backward Pas en arrière Step Forward Pas en avant Clear History Effacer l’historique + New Document from State Nouveau document à partir de l’état Set History Limit… Définir la limite de l’historique… History States États de l’historique # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1822,6 +1832,9 @@ font-technology Variable Variables Live Paint Selection Tool Outil Sélection de peinture dynamique Live Shape Properties Propriétés de la forme dynamique Load Color Profile… Charger un profil colorimétrique… + Load Character Styles… Charger les styles de caractère… + Load Paragraph Styles… Charger les styles de paragraphe… + Load All Styles… Charger tous les styles… Remove Color Profile… Supprimer un profil colorimétrique… Load Image into Clipboard Charger une image dans le presse-papiers Load Metafile into Clipboard Charger un métafichier dans le presse-papiers @@ -2687,6 +2700,14 @@ digits Malayalam Malayalam Relink Lier à nouveau Relink Linked Files to the Document Lier à nouveau les fichiers liés au document Relink to Folder… Lier à nouveau à un dossier… + Each Link Separately Chaque lien séparément + Selected Instances Occurrences sélectionnées + All Instances Toutes les occurrences + Missing Links from This Folder Liens manquants de ce dossier +@msg scope {v} is not each|selected|all|missingInFolder scope {v} n’est pas each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each et paths vont ensemble : paths liste des entrées avec ids et path, à la place de ids, path et folder +@msg paths entry {i} needs path and ids (object ids no other entry has) l’entrée {i} de paths nécessite path et ids (des identifiants d’objet qu’aucune autre entrée n’a) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder prend path : les autres liens manquants sont cherchés dans le dossier de ce fichier Relink… Lier à nouveau… Find Linked Files Rechercher des fichiers liés Find Linked Files in Folder… Rechercher des fichiers liés dans un dossier… @@ -2731,6 +2752,7 @@ digits Malayalam Malayalam Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Remplacez-le par un autre fichier ou ignorez-le : ses images continueront d’afficher l’aperçu enregistré avec le document. Replace Spine Remplacer la dorsale Replace Symbol Remplacer le symbole + Reset Transformation Réinitialiser la transformation Replace With Font Remplacer par la police Replace With Font From Remplacer par une police de System Système @@ -2747,6 +2769,8 @@ digits Malayalam Malayalam Reset OpenType Features Réinitialiser les fonctions OpenType Reset Panel Réinitialiser le panneau Reset Preferences Réinitialiser les préférences + Search Preferences Rechercher dans les préférences + No settings found Aucun réglage trouvé Reset Profiles Réinitialiser les profils Reset Rotate View Réinitialiser la rotation de l’affichage Rotate View to Selection Faire pivoter l’affichage selon la sélection @@ -2801,6 +2825,7 @@ digits Malayalam Malayalam Rotate Tool Outil Rotation Rotate View Tool Outil Faire pivoter l’affichage Trackpad Gesture to Rotate View Faire pivoter l’affichage avec un geste du trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Maintenir le raccourci d’un outil pour l’utiliser temporairement Rotate… Rotation… Rotation Rotation Roughen… Rugosité… @@ -3240,6 +3265,7 @@ digits Malayalam Malayalam Spaces Espaces Spacing Guides Repères d’espacement Invoke Distance Guides Appeler les repères de distance + Snap to Last Location Magnétisme du dernier emplacement Spacing: Espacement : Spanish Espagnol Specified Distance Distance définie @@ -3921,6 +3947,9 @@ axis Both Les deux Put back in the toolbar Remettre dans la barre d’outils Float Panel Rendre le panneau flottant Dock Panel Ancrer le panneau + Dock to Top Ancrer en haut + Dock to Bottom Ancrer en bas + Dock Control Bar Ancrer la barre de contrôle Drag to float the toolbar Faire glisser pour rendre la barre d’outils flottante Drag to the window's left edge to dock Faire glisser vers le bord gauche de la fenêtre pour ancrer Float Tool Group Rendre le groupe d’outils flottant @@ -4368,6 +4397,7 @@ axis Both Les deux @msg nothing to save: the document has no visible art rien à enregistrer : le document n’a aucune illustration visible @msg nothing to undo rien à annuler @msg no history to clear aucun historique à effacer +@msg no history state {v} aucun état d’historique {v} @msg nothing to warp rien à déformer @msg nothing to wrap rien à envelopper @msg object {0} cannot have children l’objet {0} ne peut pas avoir d’enfants @@ -4747,6 +4777,7 @@ axis Both Les deux ICF Bottom/Left ICF bas/gauche @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` est la référence d’étape {r}, que seuls `run` et `command_batch` résolvent @msg `ids` must be an array of object ids, not {v} `ids` doit être une liste d’identifiants d’objet, pas {v} +@msg `colors` must be an array of colors, not {v} `colors` doit être une liste de couleurs, pas {v} @msg `corners` must be an array of anchor indices, not {v} `corners` doit être une liste d’indices de points d’ancrage, pas {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}` : le résultat de l’étape {i} n’a pas cette valeur (écrivez $$ pour un $ littéral) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}` : il n’y a pas d’étape {i} avant celle-ci (les étapes sont comptées à partir de 0 ; écrivez $$ pour un $ littéral) @@ -5787,27 +5818,32 @@ axis Y Axis Axe Y @msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget` @msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte # Plug-in permissions (Install Plug-in…, plugin.install grant) - Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? - ID: {id} ID : {id} - Version: {version} Version : {version} - File SHA-256: {hash} SHA-256 du fichier : {hash} + Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ? + ID: {id} ID : {id} + Version: {version} Version : {version} + File SHA-256: {hash} SHA-256 du fichier : {hash} Read the objects it is given Lire les objets qui lui sont transmis Change, add and delete objects in the document Modifier, ajouter et supprimer des objets du document Keep data of its own with the document Conserver ses propres données avec le document Change how the objects its effect is applied to are drawn Modifier le rendu des objets auxquels son effet est appliqué Write the document as a file in its own format Écrire le document dans un fichier à son propre format -@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} + Show its progress, see Cancel and write log lines Afficher sa progression, détecter l’annulation et écrire des lignes de journal +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorisation `progress` nécessite le niveau d’API de module externe {PROGRESS_API} : indiquez `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} le module importe `env.{name}` avec un autre type que celui de l’hôte : celui de l’hôte est {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` le module importe `env.{name}`, qu’un module externe ne peut utiliser qu’au niveau d’API de module externe {PROGRESS_API} (`api` {PROGRESS_API}) et avec l’autorisation `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network le module importe `{from}.{name}`, que l’hôte ne propose pas : un module externe ne peut importer que `env.vc_progress`, `env.vc_cancelled` et `env.vc_log` (niveau d’API de module externe 2), et aucun WASI, fichier ni réseau +@msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` vaut {_1} ; cet hôte prend en charge les niveaux d’API de module externe 1 à {API_LEVEL} @msg `grant` must be a list of permission names `grant` doit être une liste de noms d’autorisations @msg `grant` names an unknown permission `{shown}` `grant` nomme une autorisation inconnue `{shown}` @msg a plug-in of kind `{_1}` needs the permission `{_2}` un module externe de type `{_1}` a besoin de l’autorisation `{_2}` @msg at most {MAX_PERMISSIONS} `permissions` au plus {MAX_PERMISSIONS} `permissions` @msg permission `{shown}` doesn't apply to a plug-in of kind `{_1}` l’autorisation `{shown}` ne s’applique pas à un module externe de type `{_1}` @msg permission `{shown}` is listed twice l’autorisation `{shown}` figure deux fois -@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas +@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorisation `{shown}` est réservée à une future API de module externe ; cet hôte ne l’accorde pas @msg unknown permission `{shown}` autorisation inconnue `{shown}` -@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte -@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié -@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé +@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte +@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié +@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed le module n’a pas le SHA-256 indiqué par `hash` : rien n’a été installé # Actions panel: action sets (#3712). New Set… Nouveau jeu… New Set Nouveau jeu @@ -6267,6 +6303,7 @@ breakText Glyphs Glyphes Gap 5 Écart 5 @msg {_1}: they came in as the page's art {_1} : ils ont été importés comme le dessin de la page +@msg warped text objects that came in as drawn art, not as editable type: {_1} objets texte déformés importés comme dessin et non comme texte modifiable : {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} les objets dessinés différemment de la page du fichier lui-même ont été importés comme le dessin de la page : {_1} @msg You must choose a side pattern. Vous devez choisir un motif latéral. diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 38ac80141..7a6de9d9d 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -148,6 +148,11 @@ 32 bit 32 बिट 32 bits/pixel 32 बिट्स/पिक्सेल 3D and Materials 3D और मटीरियल्स + Plane समतल + Extrude एक्सट्रूड + Revolve रिवॉल्व + Inflate फुलाएँ + Select objects, then pick an object type to give them a 3D effect. ऑब्जेक्ट्स सिलेक्ट करें, फिर उन्हें 3D इफ़ेक्ट देने के लिए एक ऑब्जेक्ट टाइप चुनें। 3D Box 3D बॉक्स 3D Box Tool 3D बॉक्स टूल 3D Lighting Preset 3D लाइटिंग प्रीसेट @@ -745,6 +750,7 @@ Clear Events घटनाओं को साफ़ करें Clear Guides गाइड्स हटाएँ Clear History हिस्ट्री साफ़ करें + New Document from State स्थिति से नया दस्तावेज़ Clear Overrides साफ़ ओवरराइड्स Clear Recent Files हाल की फ़ाइलें साफ़ करें Clear Selection सिलेक्शन साफ़ करें @@ -1299,6 +1305,9 @@ Do Not Scale स्केल न करें Do you want to save the changes you made to “{name}”? क्या आप "{name}" में किए गए परिवर्तनों को सेव करना चाहते हैं? Dock Panel पैनल डॉक करें + Dock to Top ऊपर डॉक करें + Dock to Bottom नीचे डॉक करें + Dock Control Bar नियंत्रण बार डॉक करें Document डॉक्यूमेंट Document Color Mode डॉक्यूमेंट रंग मोड Document Health Check डॉक्यूमेंट स्वास्थ्य जांच @@ -1967,6 +1976,8 @@ Grays ग्रे Grays and Neutrals ग्रे और तटस्थ Grayscale ग्रेस्केल + High Color अधिक रंग + Low Color कम रंग Grayscale images ग्रेस्केल इमेज Grayscale Images ग्रे स्केल इमेज Greek & Cyrillic ग्रीक & Cyrillic @@ -2250,6 +2261,7 @@ Invert Pie पाई उलटें Inverted Round उल्टे दौर Invoke Distance Guides दूरी गाइड्स दिखाएँ + Snap to Last Location अंतिम स्थान पर स्नैप करें Isolate पृथक Isolate Blending ब्लेंडिंग अलग करें Isolate Group अलग समूह @@ -3612,6 +3624,14 @@ Relink दोबारा लिंक करें Relink Linked Files to the Document लिंक्ड फ़ाइलें डॉक्यूमेंट से दोबारा लिंक करें Relink to Folder… फ़ोल्डर से दोबारा लिंक करें… + Each Link Separately हर लिंक अलग से + Selected Instances चुने गए इंस्टेंस + All Instances सभी इंस्टेंस + Missing Links from This Folder इस फ़ोल्डर से मिसिंग लिंक +@msg scope {v} is not each|selected|all|missingInFolder scope {v}, each|selected|all|missingInFolder में से नहीं है +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each और paths साथ में दिए जाते हैं: paths में ids और path वाली एंट्री होती हैं, ids, path और folder की जगह +@msg paths entry {i} needs path and ids (object ids no other entry has) paths की एंट्री {i} को path और ids चाहिए (ऐसे ऑब्जेक्ट ids जो किसी दूसरी एंट्री में न हों) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder, path लेता है: उस फ़ाइल के फ़ोल्डर में बाकी मिसिंग लिंक खोजे जाते हैं Relink… दोबारा लिंक करें… Reload Plug-ins प्लग-इन फिर से लोड करें Remember Import Matches याद रखें आयात मैच @@ -3684,6 +3704,7 @@ Replace Missing Fonts… अनुपलब्ध फ़ॉन्ट रिप्लेस करें… Replace Spine स्पाइन रिप्लेस करें Replace Symbol सिंबल रिप्लेस करें + Reset Transformation रूपांतरण रीसेट करें Replace With Font इस फ़ॉन्ट से रिप्लेस करें Replace With Font From इस स्रोत के फ़ॉन्ट से रिप्लेस करें Replace with Top Object सबसे ऊपर के ऑब्जेक्ट से रिप्लेस करें @@ -3714,6 +3735,8 @@ Reset Painting पेंटिंग रीसेट करें Reset Panel पैनल रीसेट करें Reset Preferences प्रेफ़रेंसेज़ रीसेट करें + Search Preferences प्रेफ़रेंसेज़ में खोजें + No settings found कोई सेटिंग नहीं मिली Reset Printing and Proofing मुद्रण और प्रूफिंग रीसेट करें Reset Profiles प्रोफ़ाइल रीसेट करें Reset Rotate View व्यू घुमाएँ रीसेट करें @@ -4818,6 +4841,7 @@ Tracing the colors… रंग अनुरेखण… Tracking ट्रैकिंग Trackpad Gesture to Rotate View दृश्य घुमाने के लिए ट्रैकपैड जेस्चर + Hold a Tool Shortcut to Use the Tool Temporarily किसी टूल का शॉर्टकट दबाए रखकर उसे अस्थायी रूप से उपयोग करें Trademark Symbol ट्रेडमार्क सिंबल Transform रूपांतरण करें Transform Again फिर से बदलना @@ -5088,6 +5112,8 @@ Video 3840×2160 वीडियो 3840 × 2160 Video Banner 2560×1440 वीडियो बैनर 2560 × 1440 Video Ruler Pixel Aspect Ratio: वीडियो शासक पिक्सेल दृष्टि अनुपात: + Fade Region Outside Artboard आर्टबोर्ड के बाहर का क्षेत्र धुंधला करें + Update While Dragging खींचते समय अपडेट करें Video Thumbnail 1280×720 वीडियो थंबनेल 1280 × 720 View व्यू View {n} {n} @@ -5198,6 +5224,11 @@ Keep data of its own with the document अपना डेटा डॉक्यूमेंट के साथ रखना Change how the objects its effect is applied to are drawn जिन ऑब्जेक्ट्स पर उसका इफ़ेक्ट लगा है, उनके बनाए जाने का तरीका बदलना Write the document as a file in its own format डॉक्यूमेंट को अपने फ़ॉर्मेट की फ़ाइल के रूप में लिखना + Show its progress, see Cancel and write log lines अपनी प्रगति दिखाना, रद्द करने का अनुरोध देखना और लॉग पंक्तियाँ लिखना +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} अनुमति `progress` के लिए प्लग-इन API स्तर {PROGRESS_API} चाहिए: `api` {PROGRESS_API} दें +@msg the module imports `env.{name}` with another type than the host's: it is {signature} मॉड्यूल `env.{name}` को होस्ट से अलग प्रकार के साथ आयात करता है: होस्ट का प्रकार {signature} है +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` मॉड्यूल `env.{name}` आयात करता है, जिसे प्लग-इन केवल प्लग-इन API स्तर {PROGRESS_API} (`api` {PROGRESS_API}) पर और अनुमति `progress` के साथ उपयोग कर सकता है +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network मॉड्यूल `{from}.{name}` आयात करता है, जिसे होस्ट उपलब्ध नहीं कराता: प्लग-इन केवल `env.vc_progress`, `env.vc_cancelled` और `env.vc_log` (प्लग-इन API स्तर 2) आयात कर सकता है, और कोई WASI, फ़ाइल या नेटवर्क नहीं @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` {_1} है; यह होस्ट प्लग-इन API स्तर 1 से {API_LEVEL} तक का समर्थन करता है @msg `grant` must be a list of permission names `grant` अनुमति नामों की सूची होनी चाहिए @msg `grant` names an unknown permission `{shown}` `grant` एक अज्ञात अनुमति `{shown}` बताता है @@ -5275,9 +5306,9 @@ @msg {k} is too long (at most {MAX_TEXT} characters) {k} बहुत लंबा है (अधिकांश {MAX_TEXT} वर्णों पर) @msg {k} must be {{size?, position?}} {k} को {{size?, position?}} होना चाहिए @msg {k} must be > 0 {k} होना चाहिए > 0 -@msg {k} must be a number (pt) or a length such as "210 mm" {k} को एक नंबर (pt) या "210 मिमी" जैसी लंबाई होना चाहिए। +@msg {k} must be a number (pt) or a length such as "210 mm" {k} को एक नंबर (pt) या "210 mm" जैसी लंबाई होना चाहिए। @msg {k} must be a number from {min} to {max} {k} को {min} से {max} के बीच की संख्या होना चाहिए -@msg {k} must be a number or "auto" {k} एक नंबर या "ऑटो" होना चाहिए। +@msg {k} must be a number or "auto" {k} एक नंबर या "auto" होना चाहिए। @msg {k} must be a string {k} एक स्ट्रिंग होना चाहिए @msg {k} must be text {k} को टेक्स्ट होना चाहिए @msg {k} must be true or false {k} को सच या झूठा होना चाहिए @@ -5467,7 +5498,7 @@ @msg `{what}` must be text `{what}` को टेक्स्ट होना चाहिए @msg `a` and `b` must be two different paths `a` और `b` को दो अलग-अलग पाथ होना चाहिए @msg `align` is start, center or end `align` शुरू, केंद्र या अंत है -@msg `align` must be "left", "center" or "right", not {other} `align` होना चाहिए "left", "सेंटर" या "right", नहीं {other} +@msg `align` must be "left", "center" or "right", not {other} `align` होना चाहिए "left", "center" या "right", नहीं {other} @msg `anchors` must be an array of [subpath, anchor] pairs `anchors` को [subpath, एंकर] जोड़े की एक सारणी होना चाहिए @msg `anchors` must list anchor indices (0, 1, 2…), not {k} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {k} नहीं @msg `anchors` must list anchor indices (0, 1, 2…), not {v} `anchors` में एंकर इंडेक्स (0, 1, 2…) की सूची होनी चाहिए, {v} नहीं @@ -5519,13 +5550,13 @@ @msg `diameter` must be from 0.25 to 200 pt `diameter` को 0.25 से 200 pt होना चाहिए @msg `digits` must be one of {_1} `digits` को {_1} में से एक होना चाहिए @msg `dimImages` is a percentage or false `dimImages` एक प्रतिशत या झूठी है -@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` को "ऑटो", "leftToRight" या "rightToLeft" होना चाहिए +@msg `direction` must be "auto", "leftToRight" or "rightToLeft" `direction` को "auto", "leftToRight" या "rightToLeft" होना चाहिए @msg `dither` applies to gradient fills (paint.setFill) `dither` gradient fills (paint.setFill) पर लागू होता है @msg `dither` applies to gradient fills (stroke: false) `dither` gradient fills (स्ट्रोक: झूठी) पर लागू होता है @msg `dither` must be true or false `dither` को सच या झूठा होना चाहिए @msg `documentData` is {size} bytes (limit {MAX_DOCUMENT_DATA}) `documentData` {size} bytes (limit {MAX_DOCUMENT_DATA}) है। @msg `each` is the value one design stands for, more than 0 `each` एक डिज़ाइन का मान है, जो 0 से अधिक होना चाहिए -@msg `end` must be "start" or "end" `end` को " start" या "end" होना चाहिए। +@msg `end` must be "start" or "end" `end` को "start" या "end" होना चाहिए। @msg `ends` applies to joining exactly two paths `ends` वास्तव में दो पाथ में शामिल होने के लिए लागू होता है @msg `event` must be one of {_1} `event` is one of the {_1} @msg `every` must be "all", "odd" or "even", not {v} `every` होना चाहिए "all", "odd" या "even", नहीं {v} @@ -5558,6 +5589,7 @@ @msg `id` must be a node id `id` एक नोड आईडी होना चाहिए @msg `id` must be an object id, not {v} `id` एक ऑब्जेक्ट आईडी होना चाहिए, नहीं {v} @msg `ids` must be an array of object ids, not {v} `ids` को ऑब्जेक्ट आईडी की ऐरे होना चाहिए, {v} नहीं +@msg `colors` must be an array of colors, not {v} `colors` को रंगों की ऐरे होना चाहिए, {v} नहीं @msg `ids` must name Objects on Path groups or objects in them `ids` को ऑब्जेक्ट्स ऑन पाथ समूह या उनके ऑब्जेक्ट बताने चाहिए @msg `ids` takes at most {MAX_INSPECT_IDS} ids `ids` में अधिकतम {MAX_INSPECT_IDS} आईडी हो सकती हैं @msg `imageMap` must be none, rectangle or polygon `imageMap` होना चाहिए कोई नहीं, आयत या बहुभुज @@ -5583,9 +5615,9 @@ @msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` में से एक होना चाहिए: आयत, अंडाकार, बहुभुज, स्टार, रिंग, तीर, क्रॉस, दिल, गियर, बुलबुला @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` को spotColors, colorEffects, rgbImages, cmykImages या grayImages होना चाहिए @msg `kinds` must be a list of issue kinds `kinds` को जारी करने के प्रकार की सूची होना चाहिए -@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` होना चाहिए "one", "hard" या "soft" होना चाहिए। +@msg `kinsoku` must be "none", "hard" or "soft" `kinsoku` "none", "hard" या "soft" होना चाहिए। @msg `label` must be a non-empty name of at most 200 characters `label` को 200 वर्णों में से एक गैर-खाली नाम होना चाहिए -@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` एक भाषा का नाम या टैग होना चाहिए, जैसे "तुर्की" या "tr" +@msg `language` must be a language name or tag, such as "Turkish" or "tr" `language` एक भाषा का नाम या टैग होना चाहिए, जैसे "Turkish" या "tr" @msg `leading` must be from 0.1 to 5000, or null for Auto `leading` को 0.1 से 5000 तक होना चाहिए, या ऑटो के लिए शून्य होना चाहिए @msg `leadingModel` must be "romanBaseline" or "emBoxTop" `leadingModel` को "romanBaseline" या "emBoxTop" होना चाहिए @msg `length` must be a number of at most 1000000 pt either way `length` को 1000000 pt में से एक होना चाहिए। @@ -5633,9 +5665,9 @@ @msg `rule` must be nonZero or evenOdd `rule` होना चाहिए nonZero or evenOdd होना चाहिए @msg `sampleSize` must be 1, 3 or 5 `sampleSize` होना चाहिए 1, 3 या 5 @msg `scale` must be a number in (0, 100] `scale` को (0, 100] के बीच की संख्या होना चाहिए -@msg `scope` must be "selection" or "document", not {other} `scope` को "सिलेक्शन" या "दस्ताव" होना चाहिए, न कि {other} +@msg `scope` must be "selection" or "document", not {other} `scope` को "selection" या "document" होना चाहिए, न कि {other} @msg `script` must be a string `script` एक स्ट्रिंग होना चाहिए -@msg `segment` must be "straight" or "curved" `segment` को "सीधे" या "ठीक" होना चाहिए। +@msg `segment` must be "straight" or "curved" `segment` को "straight" या "curved" होना चाहिए। @msg `select` must be true or false `select` को true या false होना चाहिए @msg `seriesIndexes` is a non-empty list of series indexes `seriesIndexes` श्रृंखला सूचकांकों की एक गैर-खाली सूची है @msg `seriesIndexes` needs a `type` or a `valueAxis` `seriesIndexes` को `type` या `valueAxis` की आवश्यकता होती है @@ -5646,7 +5678,7 @@ @msg `source` and `target` are the same object `source` और `target` एक ही ऑब्जेक्ट है @msg `source` must be `folder` or `datasets` `source` होना चाहिए `folder` या `datasets` होना चाहिए @msg `spacing` must be a number `spacing` होना चाहिए -@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` को कई अंक, 0 या उससे अधिक, या "ऑटो" होना चाहिए। +@msg `spacing` must be a number of points, 0 or more, or "auto" `spacing` को कई अंक, 0 या उससे अधिक, या "auto" होना चाहिए। @msg `start` must be a finite number `start` को परिमित संख्या होना चाहिए @msg `starts` must be an array of anchor indices (or nulls) `starts` को एंकर इंडेक्स (या null) की ऐरे होना चाहिए @msg `status` must be linked, embedded, missing or modified, or a list of them `status` को लिंक, एम्बेडेड, लापता या संशोधित किया जाना चाहिए, या उनमें से एक सूची @@ -5661,14 +5693,14 @@ @msg `symbol` must be a symbol name `symbol` एक सिंबल नाम होना चाहिए @msg `t` must be a number from -0.98 to 0.98 `t` को -0.98 से 0.98 के बीच की संख्या होना चाहिए @msg `t` must be from 0 to 1 `t` 0 से 1 तक होना चाहिए -@msg `target` must be "object" or "contents", not {v} `target` को "object" या "content" होना चाहिए, नहीं {v} +@msg `target` must be "object" or "contents", not {v} `target` को "object" या "contents" होना चाहिए, नहीं {v} @msg `tint` applies to global and spot colours `tint` वैश्विक और स्पॉट रंगों पर लागू होता है @msg `tint` goes with a global or spot `swatch` `tint` एक वैश्विक या स्थान `swatch` के साथ चला जाता है @msg `tint` must be a number (%) `tint` एक नंबर होना चाहिए (%) @msg `tint` needs a global or spot colour `tint` को वैश्विक या स्पॉट रंग की आवश्यकता होती है @msg `to` is already threaded `to` पहले से ही पिरोया है @msg `to` is the design's new name `to` डिजाइन का नया नाम है -@msg `to` must be "corner" or "smooth" `to` होना चाहिए "कॉर्नर" या "चिकना" होना चाहिए। +@msg `to` must be "corner" or "smooth" `to` "corner" या "smooth" होना चाहिए। @msg `to` must be [x, y] `to` होना चाहिए [x, y]] @msg `to` must be a closed path, area type or type on a path other than `from` `to` को बंद पाथ, एरिया टाइप या पाथ पर टाइप होना चाहिए और `from` से अलग होना चाहिए @msg `to` must be a whole number `to` एक पूरी संख्या होना चाहिए @@ -5840,7 +5872,7 @@ @msg Angles require two straight segments कोणों को दो सीधे खंडों की आवश्यकता होती है @msg antiAlias `{id}`: none, art or type antiAlias `{id}`: none, art या type @msg appearance must be flat|center|edge appearance को flat|center|edge होना चाहिए -@msg apply is "fill" or "stroke", not `{other}` लागू "भर" या "स्ट्रोक" है, नहीं `{other}` +@msg apply is "fill" or "stroke", not `{other}` apply "fill" या "stroke" है, नहीं `{other}` @msg area must be a closed polygon of 3 to 100000 [x, y] points area: क्षेत्र 3 से 100000 [x, y] अंक के एक बंद बहुभुज होना चाहिए @msg area type {_1} is named twice क्षेत्र के प्रकार {_1} को दो बार नाम दिया गया है @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise फ्रेम में क्षेत्र प्रकार फ़ॉन्ट की एक पंक्ति के लिए बहुत कम है, यह लंबा फ्रेम ({_1}) में दिखाता है: उनका प्रकार अन्यथा नहीं दिखाई देगा @@ -6016,7 +6048,7 @@ @msg compound paths can only contain paths यौगिक पाथ में केवल पाथ हो सकते हैं @msg compound shapes take paths, compound paths, groups, compound shapes, type, blends and envelopes मिश्रित आकार पाथ, यौगिक पाथ, समूह, मिश्रित आकार, प्रकार, मिश्रण और लिफाफे लेते हैं @msg compressed (RLE) bitmaps can't be read yet संकुचित (RLE) bitmaps अभी तक पढ़ा नहीं जा सकता है -@msg connect end must be "start" or "end" कनेक्ट अंत "शुरू" या "अंत" होना चाहिए +@msg connect end must be "start" or "end" कनेक्ट अंत "start" या "end" होना चाहिए @msg connect needs a path other than the one extended connect: कनेक्ट एक विस्तारित के अलावा एक पाथ की जरूरत है @msg connect needs id connect: कनेक्ट करने की जरूरत आईडी @msg connect path has no open end कनेक्ट पाथ का कोई खुला अंत नहीं है @@ -6128,7 +6160,7 @@ @msg empty header in column {_1} कॉलम में खाली हैडर {_1} @msg empty path खाली पाथ @msg encoding `{_1}`: utf8 or utf16 एन्कोडिंग `{_1}`: utf8 or utf16 -@msg ends must be two of "first" and "last" ends: समाप्त होता है दो "पहली" और "अंतिम" होना चाहिए। +@msg ends must be two of "first" and "last" ends: समाप्त होता है दो "first" और "last" होना चाहिए। @msg EPS {field} `{s}`: {allowed} EPS {field} `{s}`: {allowed} @msg EPS flattener: {e} ईपीएस flattener: {e} @msg EPS options: {e} ईपीएस ऑप्शन्स: {e} @@ -6269,7 +6301,7 @@ @msg gradient “{name}” isn't defined in the file: a plain colour stands in for it ग्रेडिएंट "{name}" को फ़ाइल में परिभाषित नहीं किया गया है: इसके लिए एक सादे रंग खड़ा है @msg gradient must be "objects" or "mesh" gradient: ग्रेडिएंट "objects" या "mesh" होना चाहिए @msg graphs come in as plain art, without their data or settings ग्राफ़ अपने डेटा या सेटिंग्स के बिना सादे कला के रूप में आते हैं -@msg grays must be "profile" or "black", not `{g}` grays: ग्रे "प्रोफ़ाइल" या "काला" होना चाहिए, नहीं `{g}` +@msg grays must be "profile" or "black", not `{g}` grays: ग्रे "profile" या "black" होना चाहिए, नहीं `{g}` @msg gridColors must be a preset name or two colours gridColors को प्रीसेट नाम या दो रंग होना चाहिए @msg gridSize must be small, medium or large gridSize को small, medium या large होना चाहिए @msg group must be the id of a Shaper Group group: समूह को एक शेपर ग्रुप का आईडी होना चाहिए @@ -6388,12 +6420,12 @@ @msg Join: a subpath went missing जॉइन: कोई सबपाथ नहीं मिला @msg justify must be auto|left|center|right|justifyAll justify को auto|left|center|right|justifyAll होना चाहिए @msg kbps must be a positive number, not {kbps} kbps एक सकारात्मक संख्या होना चाहिए, नहीं {kbps} -@msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "ऑटो", या "ऑप्टिकल" होना चाहिए +@msg kerning must be a number, "auto", or "optical" kerning एक संख्या, "auto", या "optical" होना चाहिए @msg keywords must be a list of words or a comma-separated string keywords: कीवर्ड शब्दों की एक सूची होना चाहिए या एक अल्पसंख्यक स्ट्रिंग होना चाहिए @msg keywords must be at most {MAX_KEYWORD} characters, without control characters keywords: कीवर्ड नियंत्रण वर्णों के बिना, अधिकांश {MAX_KEYWORD} वर्णों पर होना चाहिए @msg kind must be 1, 2 or 3, or give a preset `name` (see perspective.presets.list) kind: टाइप 1, 2 या 3 होना चाहिए, या प्रीसेट `name` देना चाहिए (see पर्सपेक्टिव.presets.list) @msg kind must be graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle or paraStyle kind को graphic, fillColor, strokeColor, fillGradient, strokeGradient, fillPattern, strokePattern, charStyle या paraStyle होना चाहिए -@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: नॉकआउट को "on", "off", "न्यूट्रल" या एक बोल, न {v} होना चाहिए +@msg knockout must be "on", "off", "neutral" or a bool, not {v} knockout: नॉकआउट को "on", "off", "neutral" या एक बोल, न {v} होना चाहिए @msg lang must be one of {_1} lang: लैंग को {_1} में से एक होना चाहिए @msg larger than 256 MB 256 MB से अधिक @msg layer `{name}` has no art to export लेयर `{name}` को निर्यात करने के लिए कोई कला नहीं है @@ -6403,7 +6435,7 @@ @msg layers go only at the top level or in layers, objects in layers and groups लेयरें केवल शीर्ष स्तर पर या लेयर्स में, लेयर्स और समूहों में ऑब्जेक्टएं @msg layers must be true or false layers: लेयरें सत्य या झूठी होनी चाहिए @msg layout must be gridByRow, gridByColumn, row or column layout को gridByRow, gridByColumn, row या column होना चाहिए -@msg leading must be a number or "auto" leading: अग्रणी एक नंबर या "ऑटो" होना चाहिए +@msg leading must be a number or "auto" leading: अग्रणी एक नंबर या "auto" होना चाहिए @msg left/right must be non-negative widths बाएं / दाएं गैर-नकारात्मक चौड़ाई होना चाहिए @msg level `{t}`: 2 or 3 स्तर `{t}`: 2 या 3 @msg library panel `{given}` isn't missing its library लाइब्रेरी पैनल `{given}` अपने लाइब्रेरी को याद नहीं कर रहा है @@ -6637,6 +6669,7 @@ @msg no guide selected कोई गाइड सिलेक्टेड नहीं @msg no guide selected (or give an index) कोई गाइड नहीं चुना गया (या एक सूचकांक देना) @msg no history to clear स्पष्ट करने के लिए कोई इतिहास नहीं +@msg no history state {v} कोई हिस्ट्री स्थिति {v} नहीं @msg no images in the folder फ़ोल्डर में कोई इमेज नहीं @msg no interaction in progress प्रगति में कोई बातचीत नहीं @msg no item at index {_1} इंडेक्स में कोई आइटम नहीं {_1} @@ -6882,6 +6915,7 @@ @msg objects can't be nested more than 32 deep ऑब्जेक्ट्स को 32 से अधिक गहराई तक घोंसला नहीं किया जा सकता है @msg objects inside a blend, envelope, repeat or Intertwine group can't be attached to a path एक मिश्रण, लिफ़ाफ़ाफ़ा, दोहराने या अंतर्जातीय समूह के अंदर ऑब्जेक्ट्स को एक पाथ से जोड़ा नहीं जा सकता है @msg objects must be none, move or copy objects: ऑब्जेक्टएं कोई नहीं, चाल या प्रतिलिपि होना चाहिए +@msg warped text objects that came in as drawn art, not as editable type: {_1} मोड़े गए टेक्स्ट ऑब्जेक्ट जो संपादन योग्य टेक्स्ट के बजाय कला के रूप में आए: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} उन ऑब्जेक्ट्स को जो फ़ाइल के स्वयं के पृष्ठ से अलग-अलग तरीके से आकर्षित होते हैं, उनकी कला के रूप में आया: {_1} @msg objects were left out ऑब्जेक्ट्स को छोड़ दिया गया था @msg objects with several fills or strokes, effects or brushes came in as their drawn look ({_1}): their appearance isn't editable yet कई भराव या स्ट्रोक, इफ़ेक्ट या ब्रश के साथ ऑब्जेक्टएं उनके ड्रॉइंग लुक ({_1}) के रूप में आईं: उनकी उपस्थिति अभी तक संपादन योग्य नहीं है @@ -7023,7 +7057,7 @@ @msg procedures nest too deeply प्रक्रियाओं घोंसला बहुत गहराई से @msg procedures nested too deeply प्रक्रियाओं ने बहुत गहराई से घोंसले @msg quotes must be {double?, single?} quotes को {double?, single?} होना चाहिए -@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए """"" +@msg quotes.{k} must be an opening and a closing quote, e.g. "“”" उद्धरण.{k} एक उद्घाटन और एक समापन उद्धरण होना चाहिए, उदाहरण के लिए "“”" @msg range `{s}` is empty श्रेणी `{s}` खाली है @msg range `{s}`: `{part}` names no number सीमा `{s}`: `{part}` कोई संख्या नहीं बताता @msg range `{s}`: `{part}` runs backwards रेंज `{s}`: `{part}` पीछे चल रहा है @@ -7204,7 +7238,7 @@ @msg Show All Hidden Attributes: nothing is hidden Show all Hidden Attributes: कुछ भी छिपा नहीं है @msg show must be true or false show: प्रदर्शन को सच या झूठा होना चाहिए @msg Simplify Anchors: the paths have too many anchors to simplify at once एंकर को सरलीकृत करें: पाथ में एक बार में सरल बनाने के लिए कई एंकर हैं -@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "छोटा", "मध्यम", "बड़ा" या 0 से अधिक अंक {_1} तक होना चाहिए +@msg size must be "small", "medium", "large" or a number of points over 0 up to {_1} size: आकार "small", "medium", "large" या 0 से अधिक अंक {_1} तक होना चाहिए @msg size must be positive size: आकार सकारात्मक होना चाहिए @msg sizeMode is "fixed" or "pressure", not {other} sizeMode को "fixed" या "pressure" होना चाहिए, {other} नहीं @msg sizeMode is "fixed" or "pressure", not `{other}` sizeMode को "fixed" या "pressure" होना चाहिए, `{other}` नहीं @@ -7214,10 +7248,10 @@ @msg sort `{x}`: name, kind or status टाइप `{x}`: नाम, प्रकार या स्थिति @msg source must be the id of a Shaper Group's original shape source: स्रोत एक शेपर ग्रुप के मूल आकार का आईडी होना चाहिए @msg source paths are too large for Shaper स्रोत पाथ शेपर के लिए बहुत बड़े हैं -@msg span must be "object" or "selection", got {v} span: स्पैन को "ऑब्जेक्ट" या "सिलेक्शन" होना चाहिए, {v} मिला +@msg span must be "object" or "selection", got {v} span: स्पैन को "object" या "selection" होना चाहिए, {v} मिला @msg span: "selection" places a `gradient`, not a swatch span: "selection" एक `gradient` रखता है, स्वॉच नहीं @msg span: "selection" places a gradient: give `gradient` span: "selection" ग्रेडिएंट रखता है: `gradient` दें -@msg span: "selection" places a linear or radial gradient, not a freeform one स्पैन: "सिलेक्शन" एक रैखिक या रेडियल ग्रेडिएंट रखता है, एक फ्रीफॉर्म नहीं एक +@msg span: "selection" places a linear or radial gradient, not a freeform one स्पैन: "selection" एक रैखिक या रेडियल ग्रेडिएंट रखता है, एक फ्रीफॉर्म नहीं एक @msg span: "selection" places the gradient itself: leave out `start` and `end` (they already place it in document coordinates) span: "selection" खुद ग्रेडिएंट रखता है: `start` और `end` न दें (वे उसे डॉक्यूमेंट निर्देशांकों पर रख देते हैं) @msg speed must be accelerated, step or pause speed: गति को तेज, कदम या रोकें होना चाहिए @msg Split Into Grid: select paths large enough for the grid स्प्लिट इनो ग्रिड: ग्रिड के लिए काफी बड़े पाथ सिलेक्ट करें @@ -7378,7 +7412,6 @@ @msg the library holds {MAX_GROUPS} groups already लाइब्रेरी में पहले से ही {MAX_GROUPS} समूह हैं। @msg the library holds {MAX_ITEMS} items of this kind already लाइब्रेरी पहले से ही इस तरह के {MAX_ITEMS} आइटम रखता है @msg the library item is damaged लाइब्रेरी आइटम क्षतिग्रस्त है -@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) मॉड्यूल आयात `{_1}.{_2}`, लेकिन प्लग-इन को कोई होस्ट आयात नहीं मिलता है (कोई WASI, फ़ाइल या नेटवर्क नहीं) @msg the module must export its linear memory as `memory` मॉड्यूल को अपनी रैखिक मेमोरी `memory` के रूप में एक्सपोर्ट करनी चाहिए @msg the name is empty नाम खाली है @msg the object already has an opacity mask ऑब्जेक्ट पहले से ही एक अपारदर्शिता मास्क है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a4404ede3..b0b517101 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -111,6 +111,11 @@ 32 bit 32 bit 32 bits/pixel 32 bit/pixel 3D and Materials 3D és anyagok + Plane Sík + Extrude Kihúzás + Revolve Forgatás tengely körül + Inflate Felfújás + Select objects, then pick an object type to give them a 3D effect. Jelöljön ki objektumokat, majd válasszon objektumtípust, hogy 3D-effektust kapjanak. 3D Ribbon 3D szalag 4 bit 4 bit 4-Up 4 nézet @@ -380,6 +385,8 @@ font-technology Variable Változó Show Cross Hairs Szálkereszt megjelenítése Show Video Safe Areas Biztonságos videoterületek megjelenítése Video Ruler Pixel Aspect Ratio: Videovonalzók képpont-méretaránya: + Fade Region Outside Artboard A rajztáblán kívüli terület elhalványítása + Update While Dragging Frissítés húzás közben Hide Video Rulers Videovonalzók elrejtése Background Contents Háttértartalom Background Contents: Háttértartalom: @@ -1073,6 +1080,9 @@ font-technology Variable Változó Do Not Scale Nincs méretezés Do you want to save the changes you made to “{name}”? Mented a módosításokat ebben: „{name}”? Dock Panel Panel dokkolása + Dock to Top Dokkolás felülre + Dock to Bottom Dokkolás alulra + Dock Control Bar Vezérlősáv dokkolása Document Dokumentum Document Color Mode Dokumentum színmódja Document Info Dokumentuminformáció @@ -1744,6 +1754,8 @@ file-filter Templates Sablonok Grays Szürkék Grays and Neutrals Szürkék és semleges színek Grayscale Szürkeárnyalatos + High Color Sok szín + Low Color Kevés szín Grayscale images Szürkeárnyalatos képek Greek & Cyrillic Görög és cirill Green Zöld @@ -1846,6 +1858,7 @@ file-filter Templates Sablonok Step Backward Lépés vissza Step Forward Lépés előre Clear History Előzmények törlése + New Document from State Új dokumentum az állapotból Set History Limit… Előzménykorlát beállítása… History States Előzmények lépésszáma # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2302,6 +2315,9 @@ file-filter Templates Sablonok Live Paint Selection Tool Élőfestés-kijelölés Live Shape Properties Élő alakzat tulajdonságai Load Color Profile… Színprofil betöltése… + Load Character Styles… Karakterstílusok betöltése… + Load Paragraph Styles… Bekezdésstílusok betöltése… + Load All Styles… Az összes stílus betöltése… Remove Color Profile… Színprofil eltávolítása… Load Image into Clipboard Kép betöltése a vágólapra Load Metafile into Clipboard Metafájl betöltése a vágólapra @@ -3215,6 +3231,14 @@ mark-style Japanese Japán Relink Újracsatolás Relink Linked Files to the Document Csatolt fájlok újracsatolása a dokumentumhoz Relink to Folder… Újracsatolás mappából… + Each Link Separately Minden csatolás külön + Selected Instances Kijelölt példányok + All Instances Összes példány + Missing Links from This Folder Hiányzó csatolások ebből a mappából +@msg scope {v} is not each|selected|all|missingInFolder a scope {v} nem each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder a scope each és a paths együtt jár: a paths ids és path elemű bejegyzéseket sorol fel az ids, path és folder helyett +@msg paths entry {i} needs path and ids (object ids no other entry has) a paths {i} bejegyzéséhez path és ids kell (olyan objektumazonosítók, amelyek más bejegyzésben nincsenek) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links a scope missingInFolder path értéket vár: a többi hiányzó csatolást a fájl mappájában keresi Relink… Újracsatolás… Find Linked Files Csatolt fájlok keresése Find Linked Files in Folder… Csatolt fájlok keresése mappában… @@ -3267,6 +3291,7 @@ mark-style Japanese Japán Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Cseréld le egy másik fájlra, vagy hagyd ki: a képei a dokumentummal mentett előnézetet mutatják tovább. Replace Spine Gerinc cseréje Replace Symbol Szimbólum cseréje + Reset Transformation Átalakítás visszaállítása Replace With Font Helyettesítő betűtípus Replace With Font From Helyettesítő betűtípus forrása System Rendszer @@ -3293,6 +3318,8 @@ mark-style Japanese Japán Reset Painting Painting visszaállítása Reset Panel Panel visszaállítása Reset Preferences Beállítások visszaállítása + Search Preferences Keresés a beállításokban + No settings found Nincs ilyen beállítás Reset Printing and Proofing Printing and Proofing visszaállítása Reset Profiles Profilok visszaállítása Reset Rotate View Nézetforgatás visszaállítása @@ -3373,6 +3400,7 @@ mark-style Japanese Japán Rotate View Nézet elforgatása Rotate View Tool Nézet elforgatása Trackpad Gesture to Rotate View Nézet elforgatása kézmozdulattal az érintőpadon + Hold a Tool Shortcut to Use the Tool Temporarily Eszköz ideiglenes használata a billentyűparancsa nyomva tartásával Rotate… Elforgatás… Rotation Elforgatás Rotation relative to: Elforgatás viszonyítási alapja: @@ -3883,6 +3911,7 @@ mark-style Japanese Japán Spacing Térköz Spacing Guides Térköz-segédvonalak Invoke Distance Guides Távolság-segédvonalak előhívása + Snap to Last Location Illesztés az utolsó helyre Spacing: Térköz: Spanish Spanyol Spatter Fröcskölés @@ -4583,6 +4612,7 @@ imageTrace Outline Körvonal @msg `groupColors` must be an array `groupColors`: tömb kell @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve az `id` lépéshivatkozás ({r}), ezt csak a `run` és a `command_batch` oldja fel @msg `ids` must be an array of object ids, not {v} `ids`: objektumazonosítók tömbje kell, ez nem az: {v} +@msg `colors` must be an array of colors, not {v} `colors`: színek tömbje kell, ez nem az: {v} @msg `library` is a library id or name (see {list}) a `library` könyvtárazonosító vagy -név lehet (lásd: {list}) @msg `mode` applies to freeform gradients a `mode` a szabad formájú színátmenetekre vonatkozik @msg `preserve` must be an object `preserve`: objektum kell @@ -5058,6 +5088,7 @@ imageTrace Outline Körvonal @msg nothing to save: the document has no visible art nincs mit menteni: a dokumentumban nincs látható grafika @msg nothing to undo nincs mit visszavonni @msg no history to clear nincs törölhető előzmény +@msg no history state {v} nincs {v} előzményállapot @msg nothing to warp nincs mit torzítani @msg nothing to wrap nincs mit körbefuttatni @msg object ids are non-negative integers, not {v} az objektumazonosító nemnegatív egész szám, ez nem az: {v} @@ -5784,6 +5815,11 @@ axis Y Axis Y tengely Keep data of its own with the document Saját adatokat tárolhat a dokumentummal Change how the objects its effect is applied to are drawn Módosíthatja, hogyan rajzolódnak ki az objektumok, amelyekre az effektusa alkalmazva van Write the document as a file in its own format Saját formátumú fájlba írhatja a dokumentumot + Show its progress, see Cancel and write log lines Megjelenítheti az előrehaladását, észlelheti a megszakítást és naplósorokat írhat +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a `progress` engedélyhez {PROGRESS_API}. szintű bővítmény-API kell: adja meg az `api` {PROGRESS_API} értéket +@msg the module imports `env.{name}` with another type than the host's: it is {signature} a modul az `env.{name}` elemet a gazdaprogramétól eltérő típussal importálja: a gazdaprogram típusa {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` a modul importálja az `env.{name}` elemet, amelyet bővítmény csak {PROGRESS_API}. szintű bővítmény-API-val (`api` {PROGRESS_API}) és a `progress` engedéllyel használhat +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network a modul importálja a(z) `{from}.{name}` elemet, amelyet a gazdaprogram nem kínál: bővítmény csak az `env.vc_progress`, `env.vc_cancelled` és `env.vc_log` elemet importálhatja (2. szintű bővítmény-API), WASI-t, fájlokat és hálózatot nem @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} az `api` értéke {_1}; ez a gazdaprogram az 1 és {API_LEVEL} közötti bővítmény-API-szinteket támogatja @msg `grant` must be a list of permission names a `grant` értékének engedélynevek listájának kell lennie @msg `grant` names an unknown permission `{shown}` a `grant` ismeretlen engedélyt nevez meg: `{shown}` @@ -6256,6 +6292,7 @@ breakText Glyphs Glifák Gap 5 Hézag 5 @msg {_1}: they came in as the page's art {_1}: az oldal rajzaként lettek importálva +@msg warped text objects that came in as drawn art, not as editable type: {_1} hajlított szövegobjektumok, amelyek rajzként, nem szerkeszthető szövegként lettek importálva: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} a fájl saját oldalától eltérően rajzolt objektumok az oldal rajzaként lettek importálva: {_1} @msg You must choose a side pattern. Válasszon oldalsó mintát. diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 763c47a2a..620d7b4dd 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -414,6 +414,11 @@ Document Raster Effects Settings… Pengaturan efek raster dokumen… Vector Effects Efek vektor 3D and Materials 3D dan material + Plane Bidang + Extrude Ekstrusi + Revolve Revolusi + Inflate Gelembungkan + Select objects, then pick an object type to give them a 3D effect. Pilih objek, lalu pilih jenis objek untuk memberinya efek 3D. Extrude & Bevel… Ekstrusi & bevel… Revolve… Revolusi… Inflate… Gelembungkan… @@ -753,6 +758,9 @@ Put back in the toolbar Kembalikan ke bilah alat Float Panel Mengambangkan panel Dock Panel Tambatkan panel + Dock to Top Tambatkan ke atas + Dock to Bottom Tambatkan ke bawah + Dock Control Bar Tambatkan bilah kontrol Float Tool Group Mengambangkan grup alat Services Layanan Hide VectorCraft Sembunyikan VectorCraft @@ -1224,6 +1232,7 @@ lighting Light Cahaya Rotate Tool Alat putar Rotate View Tool Alat putar tampilan Trackpad Gesture to Rotate View Putar tampilan dengan gestur trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tahan pintasan alat untuk menggunakan alat itu sementara Rotate View Putar tampilan Rounded Rectangle Tool Alat persegi panjang bulat Scale Tool Alat skala @@ -1546,6 +1555,7 @@ digits Malayalam Malayalam Step Backward Mundur satu langkah Step Forward Maju satu langkah Clear History Hapus riwayat + New Document from State Dokumen baru dari status Set History Limit… Atur batas riwayat… Open a document to see its history. Buka dokumen untuk melihat riwayatnya. Abutting Bersinggungan @@ -1889,6 +1899,14 @@ digits Malayalam Malayalam Location Lokasi No images Tidak ada gambar Relink to Folder… Tautkan ulang ke folder… + Each Link Separately Setiap tautan terpisah + Selected Instances Instans yang dipilih + All Instances Semua instans + Missing Links from This Folder Tautan hilang dari folder ini +@msg scope {v} is not each|selected|all|missingInFolder scope {v} bukan each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each dan paths digunakan bersama: paths mencantumkan entri berisi ids dan path, sebagai ganti ids, path, dan folder +@msg paths entry {i} needs path and ids (object ids no other entry has) entri paths {i} memerlukan path dan ids (id objek yang tidak dimiliki entri lain) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder menerima path: tautan hilang lainnya dicari di folder file tersebut Show Missing Tampilkan yang hilang Sort by Status Urutkan berdasarkan status Clear Overrides Hapus penimpaan @@ -2376,6 +2394,8 @@ digits Malayalam Malayalam Show Cross Hairs Tampilkan garis silang Show Video Safe Areas Tampilkan area aman video Video Ruler Pixel Aspect Ratio: Rasio aspek piksel penggaris video: + Fade Region Outside Artboard Redupkan wilayah di luar artboard + Update While Dragging Perbarui saat menyeret Hide Video Rulers Sembunyikan penggaris video Client-side (.html) Sisi klien (.html) Color Reduction: Reduksi warna: @@ -2945,6 +2965,7 @@ theme Appearance Tampilan Redefine Symbol Definisikan ulang simbol Remove Brush Stroke Hapus guratan kuas Replace Symbol Ganti simbol + Reset Transformation Atur ulang transformasi Select All Instances Pilih semua instansi Symbol Sprayer Penyemprot simbol Symbolism Tool Alat simbolisme @@ -2972,6 +2993,9 @@ theme Appearance Tampilan Convert Document Color Mode Konversi mode warna dokumen Gamut Warning Peringatan gamut Load Color Profile… Muat profil warna… + Load Character Styles… Muat gaya karakter… + Load Paragraph Styles… Muat gaya paragraf… + Load All Styles… Muat semua gaya… Plates Pelat Spot Color Warna spot Add to Swatches Tambahkan ke Swatches @@ -3401,6 +3425,8 @@ theme Appearance Tampilan Real-time Drawing and Editing Penggambaran dan pengeditan waktu nyata Render Threads (-1 = Automatic) Utas render (-1 = Otomatis) Reset Preferences Atur ulang preferensi + Search Preferences Cari preferensi + No settings found Tidak ada pengaturan yang ditemukan Scale Corners Skalakan sudut Scale Cursor Proportional to UI Skalakan kursor proporsional dengan UI Scale Strokes & Effects Skalakan guratan & efek @@ -3421,6 +3447,7 @@ theme Appearance Tampilan Snapping Tolerance Toleransi kancing Spacing Guides Panduan jarak Invoke Distance Guides Panggil panduan pengukuran jarak + Snap to Last Location Snap ke lokasi terakhir Spanish Spanyol Subdivisions Subdivisi Templates Folder Folder templat @@ -3588,6 +3615,8 @@ axis Both Keduanya Center Tengah Transform in Perspective Transformasi dalam perspektif Grayscale Skala abu-abu + High Color Banyak warna + Low Color Sedikit warna Embed Sematkan Star Bintang Power Saving (integrated) Hemat daya (terintegrasi) @@ -4339,6 +4368,7 @@ imageTrace Outline Kontur @msg nothing to save: the document has no visible art tidak ada yang dapat disimpan: dokumen tidak memiliki karya seni yang terlihat @msg nothing to undo tidak ada yang dapat diurungkan @msg no history to clear tidak ada riwayat yang dapat dihapus +@msg no history state {v} tidak ada status riwayat {v} @msg nothing to warp tidak ada yang dapat diubah bentuknya @msg nothing to wrap tidak ada yang dapat dibungkus @msg object {0} cannot have children objek {0} tidak dapat memiliki anak @@ -4647,6 +4677,7 @@ imageTrace Outline Kontur @msg No linked files to look for Tidak ada berkas tertaut untuk dicari @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` adalah referensi langkah {r}, yang hanya diselesaikan oleh `run` dan `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` harus berupa array id objek, bukan {v} +@msg `colors` must be an array of colors, not {v} `colors` harus berupa array warna, bukan {v} @msg `corners` must be an array of anchor indices, not {v} `corners` harus berupa array indeks jangkar, bukan {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: hasil langkah {i} tidak memiliki nilai seperti itu (tulis $$ untuk $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: tidak ada langkah {i} sebelum langkah ini (langkah dihitung mulai dari 0; tulis $$ untuk $ literal) @@ -5700,6 +5731,7 @@ axis Y Axis Sumbu Y Keep data of its own with the document Menyimpan datanya sendiri bersama dokumen Change how the objects its effect is applied to are drawn Mengubah cara objek yang diberi efeknya digambar Write the document as a file in its own format Menulis dokumen sebagai file dalam formatnya sendiri + Show its progress, see Cancel and write log lines Menampilkan kemajuannya, mengenali pembatalan, dan menulis baris log # Actions panel: action sets (#3712). New Set… Set baru… New Set Set baru @@ -6124,6 +6156,7 @@ breakText Glyphs Glif Gap 5 Jarak 5 @msg {_1}: they came in as the page's art {_1}: diimpor sebagai gambar halaman +@msg warped text objects that came in as drawn art, not as editable type: {_1} objek teks dengan Warp yang diimpor sebagai gambar, bukan sebagai teks yang dapat diedit: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objek yang tergambar berbeda dari halaman file itu sendiri diimpor sebagai gambar halaman: {_1} @msg You must choose a side pattern. Anda harus memilih pola sisi. diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index f4bde607f..b9731552d 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -84,6 +84,11 @@ 3 Color Job… Lavoro a 3 colori… 30° Angles Angoli di 30° 3D and Materials 3D e materiali + Plane Piano + Extrude Estrusione + Revolve Rivoluzione + Inflate Gonfia + Select objects, then pick an object type to give them a 3D effect. Seleziona gli oggetti, poi scegli un tipo di oggetto per dar loro un effetto 3D. 3D Ribbon Nastro 3D 45° Angles Angoli di 45° 60° Angles Angoli di 60° @@ -284,6 +289,8 @@ font-technology Variable Variabili Show Cross Hairs Mostra mirino Show Video Safe Areas Mostra aree sicure video Video Ruler Pixel Aspect Ratio: Proporzioni pixel dei righelli video: + Fade Region Outside Artboard Attenua area esterna alla tavola da disegno + Update While Dragging Aggiorna durante il trascinamento Hide Video Rulers Nascondi righelli video Background Contents Contenuto sfondo Background Contents: Contenuto sfondo: @@ -1390,6 +1397,8 @@ file-filter Templates Modelli Grays Grigi Grays and Neutrals Grigi e neutri Grayscale Scala di grigi + High Color Molti colori + Low Color Pochi colori Grayscale images Immagini in scala di grigi Greek & Cyrillic Greco e cirillico Green Verde @@ -1463,6 +1472,7 @@ file-filter Templates Modelli Step Backward Passo indietro Step Forward Passo avanti Clear History Cancella cronologia + New Document from State Nuovo documento dallo stato Set History Limit… Imposta limite cronologia… History States Stati cronologia # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1857,6 +1867,9 @@ file-filter Templates Modelli Live Paint Selection Tool Strumento Selezione pittura dinamica Live Shape Properties Proprietà forma dinamica Load Color Profile… Carica profilo colore… + Load Character Styles… Carica stili carattere… + Load Paragraph Styles… Carica stili paragrafo… + Load All Styles… Carica tutti gli stili… Remove Color Profile… Rimuovi profilo colore… Load Image into Clipboard Carica immagine negli appunti Load Metafile into Clipboard Carica metafile negli appunti @@ -2728,6 +2741,14 @@ digits Malayalam Malayalam Relink Ricollega Relink Linked Files to the Document Ricollega i file collegati al documento Relink to Folder… Ricollega a cartella… + Each Link Separately Ogni collegamento separatamente + Selected Instances Istanze selezionate + All Instances Tutte le istanze + Missing Links from This Folder Collegamenti mancanti da questa cartella +@msg scope {v} is not each|selected|all|missingInFolder scope {v} non è each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths vanno insieme: paths elenca voci con ids e path, al posto di ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) la voce {i} di paths richiede path e ids (ID di oggetti che nessun'altra voce ha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder accetta path: nella cartella di quel file si cercano gli altri collegamenti mancanti Relink… Ricollega… Find Linked Files Trova file collegati Find Linked Files in Folder… Trova file collegati in una cartella… @@ -2772,6 +2793,7 @@ digits Malayalam Malayalam Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Sostituiscilo con un altro file oppure ignoralo: le sue immagini continueranno a mostrare l'anteprima salvata con il documento. Replace Spine Sostituisci dorsale Replace Symbol Sostituisci simbolo + Reset Transformation Reimposta trasformazione Replace With Font Sostituisci con font Replace With Font From Sostituisci con font da System Sistema @@ -2788,6 +2810,8 @@ digits Malayalam Malayalam Reset OpenType Features Reimposta funzioni OpenType Reset Panel Reimposta pannello Reset Preferences Reimposta preferenze + Search Preferences Cerca nelle preferenze + No settings found Nessuna impostazione trovata Reset Profiles Reimposta profili Rotate View Ruota vista Reset Rotate View Reimposta rotazione vista @@ -2843,6 +2867,7 @@ digits Malayalam Malayalam Rotate Tool Strumento Ruota Rotate View Tool Strumento Ruota vista Trackpad Gesture to Rotate View Ruota vista con un gesto sul trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Tieni premuta la scorciatoia di uno strumento per usarlo temporaneamente Rotate… Ruota… Rotation Rotazione Roughen… Irruvidisci… @@ -3284,6 +3309,7 @@ digits Malayalam Malayalam Spaces Spazi Spacing Guides Guide di spaziatura Invoke Distance Guides Richiama guide di distanza + Snap to Last Location Aggancia all'ultima posizione Spacing: Spaziatura: Spanish Spagnolo Specified Distance Distanza specificata @@ -3973,6 +3999,9 @@ axis Both Entrambi Put back in the toolbar Riporta nella barra degli strumenti Float Panel Rendi mobile pannello Dock Panel Ancora pannello + Dock to Top Ancora in alto + Dock to Bottom Ancora in basso + Dock Control Bar Ancora barra di controllo Drag to float the toolbar Trascina per rendere mobile la barra degli strumenti Drag to the window's left edge to dock Trascina sul bordo sinistro della finestra per ancorare Float Tool Group Rendi mobile gruppo strumenti @@ -4420,6 +4449,7 @@ axis Both Entrambi @msg nothing to save: the document has no visible art niente da salvare: il documento non ha grafica visibile @msg nothing to undo niente da annullare @msg no history to clear nessuna cronologia da cancellare +@msg no history state {v} nessuno stato della cronologia {v} @msg nothing to warp niente da alterare @msg nothing to wrap niente da avvolgere @msg object {0} cannot have children l'oggetto {0} non può avere elementi figli @@ -4781,6 +4811,7 @@ axis Both Entrambi Perspective Distort Distorsione prospettica @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` è il riferimento al passaggio {r}, che solo `run` e `command_batch` risolvono @msg `ids` must be an array of object ids, not {v} `ids` deve essere un elenco di ID oggetto, non {v} +@msg `colors` must be an array of colors, not {v} `colors` deve essere un elenco di colori, non {v} @msg `corners` must be an array of anchor indices, not {v} `corners` deve essere un elenco di indici di punti di ancoraggio, non {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: il risultato del passaggio {i} non ha questo valore (scrivi $$ per un $ letterale) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: non c’è un passaggio {i} prima di questo (i passaggi si contano da 0; scrivi $$ per un $ letterale) @@ -5417,6 +5448,11 @@ axis Y Axis Asse Y Keep data of its own with the document Conservare dati propri con il documento Change how the objects its effect is applied to are drawn Cambiare il modo in cui vengono disegnati gli oggetti a cui è applicato il suo effetto Write the document as a file in its own format Scrivere il documento come file in un formato proprio + Show its progress, see Cancel and write log lines Mostrare il proprio avanzamento, rilevare l’annullamento e scrivere righe di registro +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} l’autorizzazione `progress` richiede il livello API dei plug-in {PROGRESS_API}: indicare `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} il modulo importa `env.{name}` con un tipo diverso da quello dell’host: quello dell’host è {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` il modulo importa `env.{name}`, che un plug-in può usare solo al livello API dei plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e con l’autorizzazione `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network il modulo importa `{from}.{name}`, che l’host non offre: un plug-in può importare solo `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (livello API dei plug-in 2), e nessun WASI, file o rete @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` è {_1}; questo host supporta i livelli API dei plug-in da 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` deve essere un elenco di nomi di autorizzazioni @msg `grant` names an unknown permission `{shown}` `grant` indica un’autorizzazione sconosciuta `{shown}` @@ -5892,6 +5928,7 @@ breakText Glyphs Glifi Gap 5 Spazio 5 @msg {_1}: they came in as the page's art {_1}: sono stati importati come elementi grafici della pagina +@msg warped text objects that came in as drawn art, not as editable type: {_1} oggetti di testo alterati importati come elementi grafici, non come testo modificabile: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} gli oggetti disegnati in modo diverso dalla pagina del file stesso sono stati importati come elementi grafici della pagina: {_1} @msg You must choose a side pattern. Devi scegliere un motivo laterale. diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9415aad1f..128cf09ee 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -487,6 +487,11 @@ digits Malayalam マラヤーラム数字 Document Raster Effects Settings… ドキュメントのラスタライズ効果設定… Vector Effects ベクター効果 3D and Materials 3D とマテリアル + Plane 平面 + Extrude 押し出し + Revolve 回転体 + Inflate 膨張 + Select objects, then pick an object type to give them a 3D effect. オブジェクトを選択してから、オブジェクトの種類を選んで 3D 効果を適用してください。 Extrude & Bevel… 押し出しとベベル… Revolve… 回転体… Inflate… 膨張… @@ -1029,6 +1034,8 @@ digits Malayalam マラヤーラム数字 Show Cross Hairs 十字線を表示 Show Video Safe Areas ビデオセーフエリアを表示 Video Ruler Pixel Aspect Ratio: ビデオ定規のピクセル縦横比: + Fade Region Outside Artboard アートボードの外側を暗く表示 + Update While Dragging ドラッグ中に更新 Hide Video Rulers ビデオ定規を隠す Background Contents 背景のカラー Background Contents: 背景のカラー: @@ -1729,6 +1736,8 @@ digits Malayalam マラヤーラム数字 Graphics: {adapter} グラフィックス: {adapter} Grays グレー Grayscale グレースケール + High Color 多色 + Low Color 少色 Flat Logo フラットロゴ Grayscale images グレースケールイメージ Green グリーン @@ -1774,6 +1783,7 @@ digits Malayalam マラヤーラム数字 Step Backward 一段階戻る Step Forward 一段階進む Clear History ヒストリーを消去 + New Document from State 状態から新規ドキュメント Set History Limit… ヒストリーの上限を設定… History States ヒストリー数 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2083,6 +2093,9 @@ digits Malayalam マラヤーラム数字 Live Paint Selection Tool ライブペイント選択ツール Live Shape Properties ライブシェイプのプロパティ Load Color Profile… カラープロファイルを読み込む… + Load Character Styles… 文字スタイルを読み込み… + Load Paragraph Styles… 段落スタイルを読み込み… + Load All Styles… すべてのスタイルを読み込み… Remove Color Profile… カラープロファイルを削除… Load Image into Clipboard 画像をクリップボードに読み込む Load Metafile into Clipboard メタファイルをクリップボードに読み込む @@ -2717,6 +2730,14 @@ mark-style Japanese 日本式 Release to Layers (Sequence) レイヤーに分配 (シーケンス) Relink 再リンク Relink to Folder… フォルダーに再リンク… + Each Link Separately リンクごとに個別 + Selected Instances 選択したインスタンス + All Instances すべてのインスタンス + Missing Links from This Folder このフォルダーから見つからないリンク +@msg scope {v} is not each|selected|all|missingInFolder scope {v} は each|selected|all|missingInFolder のいずれでもありません +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each と paths は一緒に指定します: paths には ids、path、folder の代わりに、ids と path を持つエントリを並べます +@msg paths entry {i} needs path and ids (object ids no other entry has) paths のエントリ {i} には path と ids (ほかのエントリにないオブジェクト ID) が必要です +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder は path を取ります: そのファイルのフォルダーで、ほかの見つからないリンクを探します Relink… 再リンク… Find Linked Files リンクファイルを検索 Find Linked Files in Folder… フォルダー内のリンクファイルを検索… @@ -2752,6 +2773,7 @@ mark-style Japanese 日本式 Replace: 置換: Replace Font フォントを置換 Replace Symbol シンボルを置き換え + Reset Transformation 変形をリセット Replace With Font 置換するフォント Replace With Font From 置換するフォントの一覧 System システム @@ -2766,6 +2788,8 @@ mark-style Japanese 日本式 Reset OpenType Features OpenType 機能をリセット Reset Panel パネルをリセット Reset Preferences 環境設定をリセット + Search Preferences 環境設定を検索 + No settings found 設定が見つかりません Reset Profiles プロファイルをリセット Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Reset to Default 初期設定に戻す @@ -2800,6 +2824,7 @@ mark-style Japanese 日本式 Rotate Tool 回転ツール Rotate View Tool 回転ビューツール Trackpad Gesture to Rotate View トラックパッドのジェスチャーでビューを回転 + Hold a Tool Shortcut to Use the Tool Temporarily ツールのショートカットを押している間だけツールを使用 Rotation 回転 Round Cap 丸型線端 Round Join ラウンド結合 @@ -3066,6 +3091,7 @@ mark-style Japanese 日本式 Spaces スペース Spacing Guides 間隔ガイド Invoke Distance Guides 距離ガイドを呼び出す + Snap to Last Location 最後の位置にスナップ Spacing: 間隔: Spanish スペイン語 Specify: 指定: @@ -3915,6 +3941,9 @@ axis Both 両方 Put back in the toolbar ツールバーに戻す Float Panel パネルをフローティング Dock Panel パネルをドッキング + Dock to Top 上部にドッキング + Dock to Bottom 下部にドッキング + Dock Control Bar コントロールバーをドッキング Drag to float the toolbar ドラッグしてツールバーをフローティング Drag to the window's left edge to dock ウィンドウの左端までドラッグしてドッキング Float Tool Group ツールグループをフローティング @@ -4168,6 +4197,7 @@ swatch Bright Violet ビビッドバイオレット @msg nothing to redo やり直す操作がありません @msg nothing to undo 取り消す操作がありません @msg no history to clear 消去するヒストリーがありません +@msg no history state {v} ヒストリーの状態 {v} はありません @msg Expand Appearance: the selection has a basic appearance アピアランスを分割: 選択範囲は基本アピアランスです @msg select (or target) objects whose appearance isn't basic 基本アピアランスではないオブジェクトを選択するか、ターゲットに指定してください @msg {C}: no effect at index {from} {C}: インデックス {from} に効果がありません @@ -4822,6 +4852,7 @@ swatch Bright Violet ビビッドバイオレット @msg No linked files to look for 検索するリンクファイルがありません @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) +@msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) @msg `corners` must be an array of anchor indices, not {v} `corners` はアンカーポイントのインデックスの配列にしてください ({v} は使えません) @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ステップ {i} の結果にその値はありません ($ そのものを書くには $$ と入力してください) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: この前にステップ {i} はありません (ステップは 0 から数えます。$ そのものを書くには $$ と入力してください) @@ -5482,6 +5513,11 @@ axis Y Axis Y 軸 Keep data of its own with the document 独自のデータをドキュメントと一緒に保存 Change how the objects its effect is applied to are drawn 効果が適用されたオブジェクトの描画方法の変更 Write the document as a file in its own format ドキュメントを独自形式のファイルとして書き出し + Show its progress, see Cancel and write log lines 進行状況の表示、キャンセルの検知、ログ行の書き込み +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 権限 `progress` にはプラグイン API レベル {PROGRESS_API} が必要です。`api` {PROGRESS_API} を指定してください +@msg the module imports `env.{name}` with another type than the host's: it is {signature} モジュールが `env.{name}` をホストと異なる型でインポートしています。ホストの型は {signature} です +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` モジュールが `env.{name}` をインポートしていますが、プラグインがこれを使用できるのはプラグイン API レベル {PROGRESS_API} (`api` {PROGRESS_API}) で権限 `progress` がある場合だけです +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network モジュールがホストの提供しない `{from}.{name}` をインポートしています。プラグインがインポートできるのは `env.vc_progress`、`env.vc_cancelled`、`env.vc_log` (プラグイン API レベル 2) だけで、WASI、ファイル、ネットワークは使用できません @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` は {_1} です。このホストが対応するプラグイン API レベルは 1 から {API_LEVEL} です @msg `grant` must be a list of permission names `grant` は権限名のリストにしてください @msg `grant` names an unknown permission `{shown}` `grant` に不明な権限 `{shown}` が含まれています @@ -5957,6 +5993,7 @@ breakText Glyphs 字形 Gap 5 間隔 5 @msg {_1}: they came in as the page's art {_1}: ページのアートとして読み込みました +@msg warped text objects that came in as drawn art, not as editable type: {_1} 編集可能なテキストではなくアートとして読み込んだワープのテキストオブジェクト: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} ファイル自身のページと描画が異なるオブジェクトをページのアートとして読み込みました: {_1} @msg You must choose a side pattern. 側面パターンを選択してください。 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index c6db1fce7..bb91ca78f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -380,6 +380,11 @@ Document Raster Effects Settings… დოკუმენტის რასტრული ეფექტების პარამეტრები… Vector Effects ვექტორული ეფექტები 3D and Materials 3D და მასალები + Plane სიბრტყე + Extrude ამოწევა + Revolve ბრუნვა + Inflate გაბერვა + Select objects, then pick an object type to give them a 3D effect. აირჩიეთ ობიექტები, შემდეგ აირჩიეთ ობიექტის ტიპი, რომ მათ 3D ეფექტი მიეცეთ. Extrude & Bevel… ამოწევა და წაკვეთა… Revolve… ბრუნვა… Inflate… გაბერვა… @@ -703,6 +708,9 @@ Put back in the toolbar ხელსაწყოთა ზოლში დაბრუნება Float Panel მცურავი პანელი Dock Panel პანელის მიმაგრება + Dock to Top ზემოთ მიმაგრება + Dock to Bottom ქვემოთ მიმაგრება + Dock Control Bar მართვის ზოლის მიმაგრება Float Tool Group მცურავი ხელსაწყოთა ჯგუფი Services სერვისები Hide VectorCraft VectorCraft-ის დამალვა @@ -1798,6 +1806,14 @@ digits Malayalam მალაიალამური Location მდებარეობა No images გამოსახულებები არ არის Relink to Folder… საქაღალდეში ხელახლა დაკავშირება… + Each Link Separately თითოეული ბმული ცალ-ცალკე + Selected Instances არჩეული ეგზემპლარები + All Instances ყველა ეგზემპლარი + Missing Links from This Folder დაკარგული ბმულები ამ საქაღალდიდან +@msg scope {v} is not each|selected|all|missingInFolder scope {v} არ არის each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each და paths ერთად გამოიყენება: paths ჩამოთვლის ჩანაწერებს ids-ით და path-ით, ids-ის, path-ისა და folder-ის ნაცვლად +@msg paths entry {i} needs path and ids (object ids no other entry has) paths-ის ჩანაწერს {i} სჭირდება path და ids (ობიექტის ID-ები, რომლებიც სხვა ჩანაწერს არ აქვს) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder იღებს path-ს: სხვა დაკარგული ბმულები ამ ფაილის საქაღალდეში იძებნება Show Missing დაკარგულების ჩვენება Sort by Status სტატუსის მიხედვით სორტირება Clear Overrides ცვლილებების გასუფთავება @@ -2279,6 +2295,8 @@ digits Malayalam მალაიალამური Show Cross Hairs ჯვარედინი ხაზების ჩვენება Show Video Safe Areas ვიდეოს უსაფრთხო არეების ჩვენება Video Ruler Pixel Aspect Ratio: ვიდეოს სახაზავების პიქსელის თანაფარდობა: + Fade Region Outside Artboard სამუშაო დაფის გარეთ არის დაბნელება + Update While Dragging განახლება გადათრევისას Hide Video Rulers ვიდეოს სახაზავების დამალვა Client-side (.html) კლიენტის მხარე (.html) Color Reduction: ფერების შემცირება: @@ -2765,6 +2783,7 @@ theme Appearance იერსახე Redefine Symbol სიმბოლოს ხელახლა განსაზღვრა Remove Brush Stroke ფუნჯის შტრიხის მოხსნა Replace Symbol სიმბოლოს ჩანაცვლება + Reset Transformation გარდაქმნის ჩამოყრა Select All Instances ყველა ეგზემპლარის მონიშვნა Symbol Sprayer სიმბოლოების სპრეი Symbolism Tool სიმბოლოების ხელსაწყო @@ -2792,6 +2811,9 @@ theme Appearance იერსახე Convert Document Color Mode დოკუმენტის ფერის რეჟიმის გარდაქმნა Gamut Warning ფერთა გამიდან გასვლის გაფრთხილება Load Color Profile… ფერის პროფილის ჩატვირთვა… + Load Character Styles… სიმბოლოს სტილების ჩატვირთვა… + Load Paragraph Styles… აბზაცის სტილების ჩატვირთვა… + Load All Styles… ყველა სტილის ჩატვირთვა… Plates ფირფიტები Spot Color სპოტ-ფერი Add to Swatches ფერის ნიმუშებში დამატება @@ -3219,6 +3241,8 @@ theme Appearance იერსახე Real-time Drawing and Editing რეალურ დროში ხატვა და რედაქტირება Render Threads (-1 = Automatic) ნაკადები (-1 = ავტომატური) Reset Preferences პარამეტრების ჩამოყრა + Search Preferences პარამეტრებში ძიება + No settings found პარამეტრები ვერ მოიძებნა Scale Corners კუთხეების მასშტაბირება Scale Cursor Proportional to UI კურსორის მასშტაბირება ინტერფეისის პროპორციულად Scale Strokes & Effects კონტურებისა და ეფექტების მასშტაბირება @@ -3239,6 +3263,7 @@ theme Appearance იერსახე Snapping Tolerance მიბმის დაშვება Spacing Guides შუალედის დამხმარე ხაზები Invoke Distance Guides მანძილის დამხმარე ხაზების გამოძახება + Snap to Last Location ბოლო მდებარეობაზე მიბმა Spanish ესპანური Subdivisions ქვედანაყოფები Templates Folder შაბლონების საქაღალდე @@ -3384,6 +3409,8 @@ axis Both ორივე Center ცენტრში Transform in Perspective პერსპექტივაში გარდაქმნა Grayscale ნაცრისფერი გრადაციები + High Color ბევრი ფერი + Low Color ცოტა ფერი Embed ჩაშენება Star ვარსკვლავი Power Saving (integrated) ენერგიის დაზოგვა (ინტეგრირებული) @@ -4441,6 +4468,7 @@ font-technology Variable ცვლადი @msg every numbered name for it is taken in the Fonts folder შრიფტების საქაღალდეში მისთვის ყველა დანომრილი სახელი დაკავებულია @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` არის ნაბიჯის მიმართვა {r}, რომელსაც მხოლოდ `run` და `command_batch` ხსნის @msg `ids` must be an array of object ids, not {v} `ids` უნდა იყოს ობიექტების id-ების მასივი და არა {v} +@msg `colors` must be an array of colors, not {v} `colors` უნდა იყოს ფერების მასივი და არა {v} @msg `corners` must be an array of anchor indices, not {v} `corners` უნდა იყოს საყრდენი წერტილების ინდექსების მასივი და არა {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: ნაბიჯ {i}-ის შედეგს ასეთი მნიშვნელობა არ აქვს (ლიტერალური $-ისთვის დაწერეთ $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: ამის წინ ნაბიჯი {i} არ არის (ნაბიჯები 0-დან ითვლება; ლიტერალური $-ისთვის დაწერეთ $$) @@ -5503,6 +5531,7 @@ imageTrace Outline კონტური The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. შენახვის პანელში სახელი იყო „{typed}“; VectorCraft-მა ფაილის ტიპის გაფართოება დაამატა. ფაილის ჩანაცვლება მის შიგთავსს გადააწერს. Thread Text ტექსტის დაკავშირება Trackpad Gesture to Rotate View ხედის შემობრუნება ტრეკპადის ჟესტით + Hold a Tool Shortcut to Use the Tool Temporarily ხელსაწყოს დროებით გამოყენება მისი მალსახმობის დაჭერით Typing აკრეფა Uniformly Scaled თანაბრად მასშტაბირებული Unlock Variables ცვლადების განბლოკვა @@ -5564,6 +5593,7 @@ imageTrace Outline კონტური @plural {n} more export waiting|{n} more exports waiting რიგში მომლოდინე სხვა ექსპორტირება: {n} @msg Export cancelled ექსპორტირება გაუქმდა Clear History ისტორიის გასუფთავება + New Document from State ახალი დოკუმენტი მდგომარეობიდან Continue in Background ფონურ რეჟიმში გაგრძელება Artboard {n} of {total} სამუშაო დაფა {n} / {total} File {n} of {total} ფაილი {n} / {total} @@ -5582,6 +5612,7 @@ imageTrace Outline კონტური @msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page მისი რედაქტირების მონაცემები {w} × {h} pt გვერდს ეკუთვნის და არა ფაილის {pw} × {ph} pt გვერდს @msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} მისი რედაქტირების მონაცემები {w} × {h} pt გვერდს ეკუთვნის: ეს ფაილის {pw} × {ph} pt გვერდია მასშტაბით {scale} @msg no history to clear გასასუფთავებელი ისტორია არ არის +@msg no history state {v} ისტორიის მდგომარეობა {v} არ არსებობს @msg {_1} files are written by file.saveAsTemplate {_1} ფაილებს წერს file.saveAsTemplate @msg aiVersion 7 writes legacy .ai files, not .ait templates aiVersion 7 წერს ძველი ფორმატის .ai ფაილებს და არა .ait შაბლონებს @msg the file's variable bindings weren't all read (bindings skipped: {_1}): those objects aren't bound to their variables ფაილის ცვლადების ყველა კავშირი არ იქნა წაკითხული (გამოტოვებული კავშირები: {_1}): ეს ობიექტები არ არის დაკავშირებული თავიანთ ცვლადებთან @@ -5724,6 +5755,11 @@ axis Y Axis Y ღერძი Keep data of its own with the document საკუთარი მონაცემების დოკუმენტთან ერთად შენახვა Change how the objects its effect is applied to are drawn იმის შეცვლა, თუ როგორ იხატება ობიექტები, რომლებზეც მისი ეფექტია გამოყენებული Write the document as a file in its own format დოკუმენტის ჩაწერა ფაილად საკუთარ ფორმატში + Show its progress, see Cancel and write log lines პროგრესის ჩვენება, გაუქმების შემჩნევა და ჟურნალის სტრიქონების ჩაწერა +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} ნებართვას `progress` სჭირდება პლაგინის API-ის დონე {PROGRESS_API}: მიუთითეთ `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} მოდულს შემოაქვს `env.{name}` ჰოსტისგან განსხვავებული ტიპით: ჰოსტის ტიპია {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` მოდულს შემოაქვს `env.{name}`, რომლის გამოყენებაც პლაგინს მხოლოდ პლაგინის API-ის დონეზე {PROGRESS_API} (`api` {PROGRESS_API}) და ნებართვით `progress` შეუძლია +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network მოდულს შემოაქვს `{from}.{name}`, რომელსაც ჰოსტი არ გვთავაზობს: პლაგინს შეუძლია შემოიტანოს მხოლოდ `env.vc_progress`, `env.vc_cancelled` და `env.vc_log` (პლაგინის API-ის დონე 2) და არა WASI, ფაილები ან ქსელი @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` არის {_1}; ეს ჰოსტი პლაგინის API-ის დონეებს 1-დან {API_LEVEL}-მდე უჭერს მხარს @msg `grant` must be a list of permission names `grant` ნებართვების სახელების სია უნდა იყოს @msg `grant` names an unknown permission `{shown}` `grant` უცნობ ნებართვას ასახელებს: `{shown}` @@ -6269,6 +6305,7 @@ breakText Glyphs გლიფები Gap 5 შუალედი 5 @msg {_1}: they came in as the page's art {_1}: იმპორტირებულია გვერდის გამოსახულების სახით +@msg warped text objects that came in as drawn art, not as editable type: {_1} გაღუნული ტექსტური ობიექტები, რომლებიც იმპორტირებულია გამოსახულების და არა რედაქტირებადი ტექსტის სახით: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} ობიექტები, რომლებიც ფაილის საკუთარი გვერდისგან განსხვავებულად იხატებოდა, იმპორტირებულია გვერდის გამოსახულების სახით: {_1} @msg You must choose a side pattern. აირჩიეთ გვერდითი ნიმუში. diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 090252435..4c93a520d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -480,6 +480,11 @@ digits Malayalam 말라얄람어 Document Raster Effects Settings… 문서 래스터 효과 설정… Vector Effects 벡터 효과 3D and Materials 3D 및 재질 + Plane 평면 + Extrude 돌출 + Revolve 회전체 + Inflate 부풀리기 + Select objects, then pick an object type to give them a 3D effect. 개체를 선택한 다음 개체 유형을 골라 3D 효과를 적용하십시오. Extrude & Bevel… 돌출 및 경사… Revolve… 회전체… Inflate… 부풀리기… @@ -1019,6 +1024,8 @@ digits Malayalam 말라얄람어 Show Cross Hairs 십자선 보기 Show Video Safe Areas 비디오 안전 영역 보기 Video Ruler Pixel Aspect Ratio: 비디오 눈금자 픽셀 종횡비: + Fade Region Outside Artboard 아트보드 외부 영역 어둡게 표시 + Update While Dragging 드래그하는 동안 업데이트 Hide Video Rulers 비디오 눈금자 숨기기 Background Contents 배경 내용 Background Contents: 배경 내용: @@ -1719,6 +1726,8 @@ digits Malayalam 말라얄람어 Graphics: {adapter} 그래픽: {adapter} Grays 회색 계열 Grayscale 그레이스케일 + High Color 많은 색상 + Low Color 적은 색상 Flat Logo 단색 로고 Grayscale images 그레이스케일 이미지 Green 녹색 @@ -1764,6 +1773,7 @@ digits Malayalam 말라얄람어 Step Backward 한 단계 뒤로 Step Forward 한 단계 앞으로 Clear History 히스토리 지우기 + New Document from State 상태에서 새 문서 만들기 Set History Limit… 히스토리 한도 설정… History States 히스토리 상태 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2073,6 +2083,9 @@ digits Malayalam 말라얄람어 Live Paint Selection Tool 라이브 페인트 선택 도구 Live Shape Properties 라이브 모양 속성 Load Color Profile… 색상 프로파일 불러오기… + Load Character Styles… 문자 스타일 불러오기… + Load Paragraph Styles… 단락 스타일 불러오기… + Load All Styles… 모든 스타일 불러오기… Remove Color Profile… 색상 프로파일 제거… Load Image into Clipboard 이미지를 클립보드로 불러오기 Load Metafile into Clipboard 메타파일을 클립보드로 불러오기 @@ -2706,6 +2719,14 @@ mark-style Japanese 일본식 Release to Layers (Sequence) 레이어로 풀기(순차) Relink 다시 연결 Relink to Folder… 폴더로 다시 연결… + Each Link Separately 각 연결 따로 + Selected Instances 선택한 인스턴스 + All Instances 모든 인스턴스 + Missing Links from This Folder 이 폴더에서 누락된 연결 +@msg scope {v} is not each|selected|all|missingInFolder scope {v}은(는) each|selected|all|missingInFolder 중 하나가 아닙니다 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each와 paths는 함께 사용합니다: paths에는 ids, path, folder 대신 ids와 path가 있는 항목을 나열합니다 +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 항목 {i}에는 path와 ids(다른 항목에 없는 개체 ID)가 필요합니다 +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder는 path를 받습니다: 그 파일의 폴더에서 다른 누락된 연결을 찾습니다 Relink… 다시 연결… Find Linked Files 연결된 파일 찾기 Find Linked Files in Folder… 폴더에서 연결된 파일 찾기… @@ -2741,6 +2762,7 @@ mark-style Japanese 일본식 Replace: 바꾸기: Replace Font 글꼴 바꾸기 Replace Symbol 심볼 바꾸기 + Reset Transformation 변형 재설정 Replace With Font 바꿀 글꼴 Replace With Font From 바꿀 글꼴 목록 System 시스템 @@ -2755,6 +2777,8 @@ mark-style Japanese 일본식 Reset OpenType Features OpenType 기능 재설정 Reset Panel 패널 재설정 Reset Preferences 환경 설정 재설정 + Search Preferences 환경 설정 검색 + No settings found 설정을 찾을 수 없음 Reset Profiles 프로필 재설정 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Reset to Default 기본값으로 재설정 @@ -2789,6 +2813,7 @@ mark-style Japanese 일본식 Rotate Tool 회전 도구 Rotate View Tool 보기 회전 도구 Trackpad Gesture to Rotate View 트랙패드 제스처로 보기 회전 + Hold a Tool Shortcut to Use the Tool Temporarily 도구 단축키를 누르고 있는 동안 도구를 임시로 사용 Rotation 회전 Round Cap 둥근 끝 Round Join 둥근 연결 @@ -3055,6 +3080,7 @@ mark-style Japanese 일본식 Spaces 공백 Spacing Guides 간격 안내선 Invoke Distance Guides 거리 안내선 호출 + Snap to Last Location 마지막 위치에 스냅 Spacing: 간격: Spanish Español Specify: 지정: @@ -3899,6 +3925,9 @@ axis Both 양쪽 Put back in the toolbar 도구 모음으로 되돌리기 Float Panel 패널 분리 Dock Panel 패널 고정 + Dock to Top 위쪽에 고정 + Dock to Bottom 아래쪽에 고정 + Dock Control Bar 컨트롤 막대 고정 Drag to float the toolbar 드래그하여 도구 모음 분리 Drag to the window's left edge to dock 창 왼쪽 가장자리로 드래그하여 고정 Float Tool Group 도구 그룹 분리 @@ -4095,6 +4124,7 @@ theme Appearance 외관 @msg nothing to redo 다시 실행할 항목이 없습니다 @msg nothing to undo 실행 취소할 항목이 없습니다 @msg no history to clear 지울 히스토리가 없습니다 +@msg no history state {v} 히스토리 상태 {v}이(가) 없습니다 @msg Expand Appearance: the selection has a basic appearance 모양새 확장: 선택 항목은 기본 모양새입니다 @msg select (or target) objects whose appearance isn't basic 모양새가 기본이 아닌 개체를 선택(또는 대상 지정)하세요 @msg {C}: no effect at index {from} {C}: 인덱스 {from}에 효과가 없습니다 @@ -4749,6 +4779,7 @@ theme Appearance 외관 @msg No linked files to look for 찾을 링크된 파일이 없습니다 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 +@msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 @msg `corners` must be an array of anchor indices, not {v} `corners`는 {v}이(가) 아니라 앵커 인덱스 배열이어야 합니다 @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: 단계 {i}의 결과에 해당 값이 없습니다(문자 그대로의 $는 $$로 쓰세요) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: 이 단계 앞에 단계 {i}이(가) 없습니다(단계는 0부터 셉니다. 문자 그대로의 $는 $$로 쓰세요) @@ -5378,6 +5409,11 @@ axis Y Axis Y축 Keep data of its own with the document 자체 데이터를 문서와 함께 보관 Change how the objects its effect is applied to are drawn 효과가 적용된 개체가 그려지는 방식 변경 Write the document as a file in its own format 문서를 자체 형식의 파일로 쓰기 + Show its progress, see Cancel and write log lines 진행률 표시, 취소 감지 및 로그 줄 쓰기 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` 권한에는 플러그인 API 수준 {PROGRESS_API}이(가) 필요합니다. `api` {PROGRESS_API}을(를) 지정하십시오 +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 모듈이 `env.{name}`을(를) 호스트와 다른 형식으로 가져옵니다. 호스트의 형식은 {signature}입니다 +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 모듈이 `env.{name}`을(를) 가져오지만, 플러그인은 이를 플러그인 API 수준 {PROGRESS_API}(`api` {PROGRESS_API})에서 `progress` 권한이 있을 때만 사용할 수 있습니다 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 모듈이 호스트가 제공하지 않는 `{from}.{name}`을(를) 가져옵니다. 플러그인은 `env.vc_progress`, `env.vc_cancelled`, `env.vc_log`(플러그인 API 수준 2)만 가져올 수 있으며 WASI, 파일, 네트워크는 사용할 수 없습니다 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api`가 {_1}입니다. 이 호스트는 플러그인 API 수준 1부터 {API_LEVEL}까지 지원합니다 @msg `grant` must be a list of permission names `grant`는 권한 이름 목록이어야 합니다 @msg `grant` names an unknown permission `{shown}` `grant`에 알 수 없는 권한 `{shown}`이(가) 있습니다 @@ -5853,6 +5889,7 @@ breakText Glyphs 글리프 Gap 5 간격 5 @msg {_1}: they came in as the page's art {_1}: 페이지의 아트로 가져왔습니다 +@msg warped text objects that came in as drawn art, not as editable type: {_1} 편집 가능한 문자가 아닌 아트로 가져온 왜곡된 문자 오브젝트: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} 파일 자체 페이지와 다르게 그려진 오브젝트를 페이지의 아트로 가져왔습니다: {_1} @msg You must choose a side pattern. 측면 패턴을 선택해야 합니다. diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 875bc733c..52843c851 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -428,6 +428,11 @@ Document Raster Effects Settings… Instellingen voor documentrastereffecten… Vector Effects Vectoreffecten 3D and Materials 3D en materialen + Plane Vlak + Extrude Extruderen + Revolve Draaien + Inflate Opblazen + Select objects, then pick an object type to give them a 3D effect. Selecteer objecten en kies dan een objecttype om ze een 3D-effect te geven. Extrude & Bevel… Extruderen en afschuinen… Revolve… Draaien… Inflate… Opblazen… @@ -767,6 +772,9 @@ Put back in the toolbar Terug in de werkbalk zetten Float Panel Paneel laten zweven Dock Panel Paneel dokken + Dock to Top Bovenaan dokken + Dock to Bottom Onderaan dokken + Dock Control Bar Bedieningsbalk dokken Float Tool Group Gereedschapsgroep laten zweven Services Diensten Hide VectorCraft VectorCraft verbergen @@ -1238,6 +1246,7 @@ lighting Light Licht Rotate Tool Draaien-gereedschap Rotate View Tool Weergave draaien-gereedschap Trackpad Gesture to Rotate View Weergave draaien met trackpadgebaar + Hold a Tool Shortcut to Use the Tool Temporarily Sneltoets van een gereedschap ingedrukt houden om het tijdelijk te gebruiken Rotate View Weergave draaien Rounded Rectangle Tool Afgeronde rechthoek-gereedschap Scale Tool Schalen-gereedschap @@ -1560,6 +1569,7 @@ digits Malayalam Malayalam Step Backward Stap terug Step Forward Stap vooruit Clear History Historie wissen + New Document from State Nieuw document uit status Set History Limit… Historielimiet instellen… Open a document to see its history. Open een document om de historie te zien. Abutting Aaneengesloten @@ -1903,6 +1913,14 @@ digits Malayalam Malayalam Location Locatie No images Geen afbeeldingen Relink to Folder… Opnieuw koppelen aan map… + Each Link Separately Elke koppeling afzonderlijk + Selected Instances Geselecteerde instanties + All Instances Alle instanties + Missing Links from This Folder Ontbrekende koppelingen uit deze map +@msg scope {v} is not each|selected|all|missingInFolder scope {v} is niet each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each en paths horen bij elkaar: paths bevat items met ids en path, in plaats van ids, path en folder +@msg paths entry {i} needs path and ids (object ids no other entry has) item {i} van paths heeft path en ids nodig (object-id's die geen ander item heeft) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder neemt path: in de map van dat bestand wordt naar de andere ontbrekende koppelingen gezocht Show Missing Ontbrekende weergeven Sort by Status Sorteren op status Clear Overrides Onderdrukkingen wissen @@ -2390,6 +2408,8 @@ digits Malayalam Malayalam Show Cross Hairs Dradenkruis tonen Show Video Safe Areas Veilige videogebieden tonen Video Ruler Pixel Aspect Ratio: Pixelverhouding van videolinialen: + Fade Region Outside Artboard Gebied buiten tekenbord dimmen + Update While Dragging Bijwerken tijdens slepen Hide Video Rulers Videolinialen verbergen Client-side (.html) Client-side (.html) Color Reduction: Kleurreductie: @@ -2959,6 +2979,7 @@ theme Appearance Vormgeving Redefine Symbol Symbool opnieuw definiëren Remove Brush Stroke Penseelstreek verwijderen Replace Symbol Symbool vervangen + Reset Transformation Transformatie opnieuw instellen Select All Instances Alle exemplaren selecteren Symbol Sprayer Symboolspuit Symbolism Tool Symbolisatiegereedschap @@ -2986,6 +3007,9 @@ theme Appearance Vormgeving Convert Document Color Mode Kleurmodus van document omzetten Gamut Warning Gamutwaarschuwing Load Color Profile… Kleurprofiel laden… + Load Character Styles… Tekenstijlen laden… + Load Paragraph Styles… Alineastijlen laden… + Load All Styles… Alle stijlen laden… Plates Platen Spot Color Steunkleur Add to Swatches Aan stalen toevoegen @@ -3415,6 +3439,8 @@ theme Appearance Vormgeving Real-time Drawing and Editing Tekenen en bewerken in realtime Render Threads (-1 = Automatic) Renderthreads (-1 = automatisch) Reset Preferences Voorkeuren herstellen + Search Preferences Voorkeuren doorzoeken + No settings found Geen instellingen gevonden Scale Corners Hoeken schalen Scale Cursor Proportional to UI Cursor proportioneel schalen met UI Scale Strokes & Effects Streken & effecten schalen @@ -3435,6 +3461,7 @@ theme Appearance Vormgeving Snapping Tolerance Snapping-tolerantie Spacing Guides Hulplijnen voor tussenruimte Invoke Distance Guides Afstandshulplijnen oproepen + Snap to Last Location Uitlijnen op laatste locatie Spanish Spaans Subdivisions Onderverdelingen Templates Folder Sjablonenmap @@ -3602,6 +3629,8 @@ axis Both Beide Center Midden Transform in Perspective In perspectief transformeren Grayscale Grijswaarden + High Color Veel kleuren + Low Color Weinig kleuren Embed Insluiten Star Ster Power Saving (integrated) Energiebesparend (geïntegreerd) @@ -4351,6 +4380,7 @@ imageTrace Outline Contour @msg nothing to save: the document has no visible art niets om op te slaan: het document heeft geen zichtbare illustratie @msg nothing to undo niets om ongedaan te maken @msg no history to clear geen historie om te wissen +@msg no history state {v} geen historiestatus {v} @msg nothing to warp niets om te buigen @msg nothing to wrap niets om te laten omlopen @msg object {0} cannot have children object {0} kan geen onderliggende objecten hebben @@ -4659,6 +4689,7 @@ imageTrace Outline Contour @msg No linked files to look for Geen gekoppelde bestanden om naar te zoeken @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` is de stapverwijzing {r}, die alleen `run` en `command_batch` oplossen @msg `ids` must be an array of object ids, not {v} `ids` moet een array van object-id's zijn, niet {v} +@msg `colors` must be an array of colors, not {v} `colors` moet een array van kleuren zijn, niet {v} @msg `corners` must be an array of anchor indices, not {v} `corners` moet een array van ankerindices zijn, niet {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: het resultaat van stap {i} heeft geen dergelijke waarde (schrijf $$ voor een letterlijke $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: er is geen stap {i} vóór deze stap (stappen tellen vanaf 0; schrijf $$ voor een letterlijke $) @@ -5746,6 +5777,7 @@ axis Y Axis Y-as Keep data of its own with the document Eigen gegevens bij het document bewaren Change how the objects its effect is applied to are drawn Wijzigen hoe de objecten waarop het effect is toegepast worden getekend Write the document as a file in its own format Het document als bestand in een eigen indeling schrijven + Show its progress, see Cancel and write log lines De voortgang tonen, een annulering zien en logregels schrijven # Actions panel: action sets (#3712). New Set… Nieuwe set… New Set Nieuwe set @@ -6170,6 +6202,7 @@ breakText Glyphs Glyphs Gap 5 Tussenruimte 5 @msg {_1}: they came in as the page's art {_1}: ze zijn geïmporteerd als pagina-afbeelding +@msg warped text objects that came in as drawn art, not as editable type: {_1} tekstobjecten met Warp die zijn geïmporteerd als afbeelding, niet als bewerkbare tekst: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objecten die anders werden getekend dan de eigen pagina van het bestand, zijn geïmporteerd als pagina-afbeelding: {_1} @msg You must choose a side pattern. Kies een zijpatroon. diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 37ad72246..4467230b7 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -416,6 +416,11 @@ Document Raster Effects Settings… Ustawienia efektów rastrowych dokumentu… Vector Effects Efekty wektorowe 3D and Materials 3D i materiały + Plane Płaszczyzna + Extrude Wyciągnięcie + Revolve Obrót + Inflate Nadmuchanie + Select objects, then pick an object type to give them a 3D effect. Zaznacz obiekty, a następnie wybierz typ obiektu, aby nadać im efekt 3D. Extrude & Bevel… Wyciągnięcie i fazowanie… Revolve… Obrót… Inflate… Nadmuchanie… @@ -765,6 +770,9 @@ Put back in the toolbar Odłóż z powrotem na pasek narzędzi Float Panel Odepnij panel Dock Panel Zadokuj panel + Dock to Top Zadokuj u góry + Dock to Bottom Zadokuj u dołu + Dock Control Bar Zadokuj pasek sterowania Float Tool Group Odepnij grupę narzędzi Services Usługi Hide VectorCraft Ukryj VectorCraft @@ -1233,6 +1241,7 @@ lighting Light Światło Rotate Tool Narzędzie Obrót Rotate View Tool Narzędzie Obrót widoku Trackpad Gesture to Rotate View Obracaj widok gestem na gładziku + Hold a Tool Shortcut to Use the Tool Temporarily Używaj narzędzia tymczasowo, przytrzymując jego skrót Rounded Rectangle Tool Narzędzie Zaokrąglony prostokąt Scale Tool Narzędzie Skalowanie Scallop Tool Narzędzie Ząbkowanie @@ -1549,6 +1558,7 @@ digits Malayalam Malajalam Step Backward Krok wstecz Step Forward Krok naprzód Clear History Wyczyść historię + New Document from State Nowy dokument ze stanu Set History Limit… Ustaw limit historii… Open a document to see its history. Otwórz dokument, aby zobaczyć jego historię. # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2003,6 +2013,14 @@ digits Malayalam Malajalam Location Położenie No images Brak obrazów Relink to Folder… Połącz ponownie z folderem… + Each Link Separately Każde łącze osobno + Selected Instances Zaznaczone wystąpienia + All Instances Wszystkie wystąpienia + Missing Links from This Folder Brakujące łącza z tego folderu +@msg scope {v} is not each|selected|all|missingInFolder scope {v} nie jest jednym z each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each i paths występują razem: paths wymienia wpisy z ids i path zamiast ids, path i folder +@msg paths entry {i} needs path and ids (object ids no other entry has) wpis {i} w paths wymaga path i ids (identyfikatorów obiektów, których nie ma żaden inny wpis) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder przyjmuje path: w folderze tego pliku szukane są pozostałe brakujące łącza Show Missing Pokaż brakujące Sort by Status Sortuj według stanu Clear Overrides Wyczyść zastąpienia @@ -2502,6 +2520,8 @@ digits Malayalam Malajalam Show Cross Hairs Pokaż krzyż nitkowy Show Video Safe Areas Pokaż bezpieczne obszary wideo Video Ruler Pixel Aspect Ratio: Proporcje piksela linijek wideo: + Fade Region Outside Artboard Przyciemnij obszar poza obszarem kompozycji + Update While Dragging Aktualizuj podczas przeciągania Hide Video Rulers Ukryj linijki wideo Client-side (.html) Po stronie klienta (.html) Color Reduction: Redukcja kolorów: @@ -3080,6 +3100,7 @@ theme Appearance Wygląd Redefine Symbol Zdefiniuj ponownie symbol Remove Brush Stroke Usuń pociągnięcie pędzla Replace Symbol Zastąp symbol + Reset Transformation Resetuj przekształcenie Select All Instances Zaznacz wszystkie wystąpienia Symbol Sprayer Rozpylanie symboli Symbolism Tool Narzędzie symboli @@ -3107,6 +3128,9 @@ theme Appearance Wygląd Convert Document Color Mode Konwertuj tryb koloru dokumentu Gamut Warning Ostrzeżenie gamutu Load Color Profile… Wczytaj profil kolorów… + Load Character Styles… Wczytaj style znakowe… + Load Paragraph Styles… Wczytaj style akapitowe… + Load All Styles… Wczytaj wszystkie style… Remove Color Profile… Usuń profil kolorów… Plates Płyty Spot Color Kolor dodatkowy @@ -3536,6 +3560,8 @@ theme Appearance Wygląd Real-time Drawing and Editing Rysowanie i edycja w czasie rzeczywistym Render Threads (-1 = Automatic) Wątki renderowania (-1 = automatycznie) Reset Preferences Resetuj preferencje + Search Preferences Szukaj w preferencjach + No settings found Nie znaleziono ustawień Scale Corners Skaluj narożniki Scale Cursor Proportional to UI Skaluj kursor proporcjonalnie do interfejsu Scale Strokes & Effects Skaluj obrysy i efekty @@ -3556,6 +3582,7 @@ theme Appearance Wygląd Snapping Tolerance Tolerancja przyciągania Spacing Guides Linie odstępów Invoke Distance Guides Wywołuj linie odległości + Snap to Last Location Przyciągaj do ostatniego położenia Spanish Hiszpański Subdivisions Podpodziały Templates Folder Folder szablonów @@ -3703,6 +3730,8 @@ axis Both Obie Center Środek Transform in Perspective Przekształć w perspektywie Grayscale Skala szarości + High Color Wiele kolorów + Low Color Mało kolorów Embed Osadź Star Gwiazda Power Saving (integrated) Oszczędzanie energii (zintegrowany) @@ -4509,6 +4538,7 @@ imageTrace Outline Kontur @msg nothing to save: the document has no visible art nie ma czego zapisać: dokument nie ma widocznej kompozycji @msg nothing to undo nie ma czego cofnąć @msg no history to clear brak historii do wyczyszczenia +@msg no history state {v} brak stanu historii {v} @msg nothing to warp nie ma czego wypaczyć @msg nothing to wrap nie ma czego oblewać @msg object {0} cannot have children obiekt {0} nie może mieć elementów podrzędnych @@ -4845,6 +4875,7 @@ imageTrace Outline Kontur @msg No linked files to look for Brak połączonych plików do wyszukania @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} +@msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} @msg `corners` must be an array of anchor indices, not {v} `corners` musi być tablicą indeksów punktów, nie {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: wynik kroku {i} nie ma takiej wartości (wpisz $$, aby uzyskać znak $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: przed tym krokiem nie ma kroku {i} (kroki liczone od 0; wpisz $$, aby uzyskać znak $) @@ -5654,6 +5685,11 @@ axis Y Axis Oś Y Keep data of its own with the document Przechowywać własne dane w dokumencie Change how the objects its effect is applied to are drawn Zmieniać sposób rysowania obiektów, do których zastosowano jej efekt Write the document as a file in its own format Zapisywać dokument jako plik we własnym formacie + Show its progress, see Cancel and write log lines Pokazywać swój postęp, wykrywać anulowanie i zapisywać wiersze dziennika +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} uprawnienie `progress` wymaga poziomu API wtyczek {PROGRESS_API}: podaj `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} moduł importuje `env.{name}` z innym typem niż typ hosta: typ hosta to {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` moduł importuje `env.{name}`, którego wtyczka może używać tylko na poziomie API wtyczek {PROGRESS_API} (`api` {PROGRESS_API}) i z uprawnieniem `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network moduł importuje `{from}.{name}`, którego host nie udostępnia: wtyczka może importować tylko `env.vc_progress`, `env.vc_cancelled` i `env.vc_log` (poziom API wtyczek 2), bez WASI, plików i sieci @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` ma wartość {_1}; ten host obsługuje poziomy API wtyczek od 1 do {API_LEVEL} @msg `grant` must be a list of permission names `grant` musi być listą nazw uprawnień @msg `grant` names an unknown permission `{shown}` `grant` wymienia nieznane uprawnienie `{shown}` @@ -6127,6 +6163,7 @@ breakText Glyphs Glify Gap 5 Odstęp 5 @msg {_1}: they came in as the page's art {_1}: zaimportowano je jako grafikę strony +@msg warped text objects that came in as drawn art, not as editable type: {_1} wypaczone obiekty tekstowe zaimportowane jako grafika, a nie jako edytowalny tekst: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} obiekty rysowane inaczej niż własna strona pliku zaimportowano jako grafikę strony: {_1} @msg You must choose a side pattern. Musisz wybrać wzór boczny. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 8510a00c7..3fae0ea3a 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -96,6 +96,11 @@ 30° Angles Ângulos de 30° 3D Ribbon Fita 3D 3D and Materials 3D e Materiais + Plane Plano + Extrude Extrusão + Revolve Revolver + Inflate Inflar + Select objects, then pick an object type to give them a 3D effect. Selecione objetos e escolha um tipo de objeto para dar a eles um efeito 3D. 45° Angles Ângulos de 45° 60° Angles Ângulos de 60° 90° & 45° & 30° Angles Ângulos de 90° & 45° & 30° @@ -270,6 +275,8 @@ Show Cross Hairs Mostrar Linhas Cruzadas Show Video Safe Areas Mostrar Áreas Seguras de Vídeo Video Ruler Pixel Aspect Ratio: Proporção de Pixel das Réguas de Vídeo: + Fade Region Outside Artboard Escurecer Região Fora da Prancheta + Update While Dragging Atualizar ao Arrastar Hide Video Rulers Ocultar Réguas de Vídeo Background Contents Conteúdo do Plano de Fundo Background Contents: Conteúdo do Plano de Fundo: @@ -1205,6 +1212,8 @@ Grays Cinzas Grays and Neutrals Cinzas e Neutros Grayscale Escala de Cinza + High Color Muitas Cores + Low Color Poucas Cores Grayscale images Imagens em escala de cinza Green Verde Grid Grade @@ -1270,6 +1279,7 @@ Step Backward Voltar um Passo Step Forward Avançar um Passo Clear History Limpar Histórico + New Document from State Novo Documento do Estado Set History Limit… Definir Limite do Histórico… History States Estados do Histórico # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1552,6 +1562,9 @@ Live Paint Selection Tool Ferramenta Seleção de Pintura ao Vivo Live Shape Properties Propriedades de Forma ao Vivo Load Color Profile… Carregar Perfil de Cor… + Load Character Styles… Carregar Estilos de Caractere… + Load Paragraph Styles… Carregar Estilos de Parágrafo… + Load All Styles… Carregar Todos os Estilos… Remove Color Profile… Remover Perfil de Cor… Load Image into Clipboard Carregar Imagem na Área de Transferência Load Metafile into Clipboard Carregar Metarquivo na Área de Transferência @@ -2246,6 +2259,14 @@ digits Malayalam Malaiala Release with Perspective Liberar com Perspectiva Relink Revinculcar Relink to Folder… Revinculcar Pasta… + Each Link Separately Cada Vínculo Separadamente + Selected Instances Instâncias Selecionadas + All Instances Todas as Instâncias + Missing Links from This Folder Vínculos Ausentes Desta Pasta +@msg scope {v} is not each|selected|all|missingInFolder scope {v} não é each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each e paths andam juntos: paths lista entradas com ids e path, no lugar de ids, path e folder +@msg paths entry {i} needs path and ids (object ids no other entry has) a entrada {i} de paths precisa de path e ids (ids de objeto que nenhuma outra entrada tenha) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder recebe path: os outros vínculos ausentes são procurados na pasta desse arquivo Relink… Revinculcar… Reload Plug-ins Recarregar Plug-ins Remove Remover @@ -2281,6 +2302,7 @@ digits Malayalam Malaiala Replace Font Substituir Fonte Replace Spine Substituir Coluna Replace Symbol Substituir Símbolo + Reset Transformation Redefinir Transformação Replace With Font Substituir Com Fonte Replace With Font From Substituir Com Fonte De System Sistema @@ -2298,6 +2320,8 @@ digits Malayalam Malaiala Reset OpenType Features Redefinir Recursos OpenType Reset Panel Redefinir Painel Reset Preferences Redefinir Preferências + Search Preferences Pesquisar nas Preferências + No settings found Nenhuma configuração encontrada Reset Profiles Redefinir Perfis Rotate View Girar Visualização Reset Rotate View Redefinir Visualização Girada @@ -2344,6 +2368,7 @@ digits Malayalam Malaiala Rotate Tool Ferramenta Girar Rotate View Tool Ferramenta Girar Visualização Trackpad Gesture to Rotate View Girar Visualização com Gesto do Trackpad + Hold a Tool Shortcut to Use the Tool Temporarily Manter o Atalho de uma Ferramenta Pressionado para Usá-la Temporariamente Rotate… Girar… Rotation Rotação Roughen… Enrugar… @@ -2711,6 +2736,7 @@ digits Malayalam Malaiala Spaces Espaços Spacing Guides Guias de Espaçamento Invoke Distance Guides Invocar Guias de Distância + Snap to Last Location Ajustar ao Último Local Spacing: Espaçamento: Spanish Espanhol Specify: Especificar: @@ -3187,6 +3213,9 @@ imageTrace Outline Contorno Put back in the toolbar Devolver à barra de ferramentas Float Panel Flutuar Painel Dock Panel Encaixar Painel + Dock to Top Encaixar na Parte Superior + Dock to Bottom Encaixar na Parte Inferior + Dock Control Bar Encaixar Barra de Controle Float Tool Group Flutuar Grupo de Ferramentas Services Serviços Hide VectorCraft Ocultar o VectorCraft @@ -3957,6 +3986,7 @@ axis Both Ambos @msg nothing to save: the document has no visible art nada para salvar: o documento não tem arte visível @msg nothing to undo nada para desfazer @msg no history to clear nenhum histórico para limpar +@msg no history state {v} nenhum estado do histórico {v} @msg nothing to warp nada para deformar @msg nothing to wrap nada para envolver @msg object {0} cannot have children o objeto {0} não pode ter filhos @@ -4283,6 +4313,7 @@ axis Both Ambos Artboard Prancheta @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` é a referência de etapa {r}, que somente `run` e `command_batch` resolvem @msg `ids` must be an array of object ids, not {v} `ids` deve ser uma matriz de ids de objeto, não {v} +@msg `colors` must be an array of colors, not {v} `colors` deve ser uma matriz de cores, não {v} @msg `corners` must be an array of anchor indices, not {v} `corners` deve ser uma matriz de índices de âncora, não {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: o resultado da etapa {i} não tem esse valor (escreva $$ para um $ literal) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: não há etapa {i} antes desta (as etapas contam a partir de 0; escreva $$ para um $ literal) @@ -5716,6 +5747,11 @@ axis Y Axis Eixo Y Keep data of its own with the document Guardar dados próprios com o documento Change how the objects its effect is applied to are drawn Alterar como são desenhados os objetos aos quais seu efeito é aplicado Write the document as a file in its own format Gravar o documento como um arquivo em formato próprio + Show its progress, see Cancel and write log lines Mostrar seu progresso, detectar o cancelamento e gravar linhas de log +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} a permissão `progress` exige o nível de API de plug-in {PROGRESS_API}: informe `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} o módulo importa `env.{name}` com um tipo diferente do tipo do host: o do host é {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` o módulo importa `env.{name}`, que um plug-in só pode usar no nível de API de plug-in {PROGRESS_API} (`api` {PROGRESS_API}) e com a permissão `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network o módulo importa `{from}.{name}`, que o host não oferece: um plug-in só pode importar `env.vc_progress`, `env.vc_cancelled` e `env.vc_log` (nível de API de plug-in 2), e nenhum WASI, arquivo ou rede @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` é {_1}; este host aceita os níveis de API de plug-in de 1 a {API_LEVEL} @msg `grant` must be a list of permission names `grant` deve ser uma lista de nomes de permissões @msg `grant` names an unknown permission `{shown}` `grant` cita uma permissão desconhecida `{shown}` @@ -6190,6 +6226,7 @@ breakText Glyphs Glifos Gap 5 Lacuna 5 @msg {_1}: they came in as the page's art {_1}: foram importados como arte da página +@msg warped text objects that came in as drawn art, not as editable type: {_1} objetos de texto distorcidos importados como arte, não como texto editável: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} os objetos desenhados de forma diferente da própria página do arquivo foram importados como arte da página: {_1} @msg You must choose a side pattern. Você deve escolher um padrão lateral. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 4c6aa7564..33f826525 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -82,6 +82,11 @@ 3 Color Job… Задание с 3 цветами… 30° Angles Углы 30° 3D and Materials 3D и материалы + Plane Плоскость + Extrude Выдавливание + Revolve Вращение + Inflate Надувание + Select objects, then pick an object type to give them a 3D effect. Выделите объекты, затем выберите тип объекта, чтобы придать им 3D-эффект. 3D Ribbon 3D-лента 45° Angles Углы 45° 60° Angles Углы 60° @@ -282,6 +287,8 @@ font-technology Variable Вариативные Show Cross Hairs Показать перекрестие Show Video Safe Areas Показать безопасные зоны видео Video Ruler Pixel Aspect Ratio: Соотношение сторон пикселя видео-линеек: + Fade Region Outside Artboard Затемнять область вне монтажной области + Update While Dragging Обновлять при перетаскивании Hide Video Rulers Скрыть видео-линейки Background Contents Содержимое фона Background Contents: Содержимое фона: @@ -1387,6 +1394,8 @@ file-filter Templates Шаблоны Grays Серые Grays and Neutrals Серые и нейтральные Grayscale Оттенки серого + High Color Много цветов + Low Color Мало цветов Grayscale images Изображения в оттенках серого Greek & Cyrillic Греческий и кириллица Green Зелёный @@ -1460,6 +1469,7 @@ file-filter Templates Шаблоны Step Backward Шаг назад Step Forward Шаг вперёд Clear History Очистить историю + New Document from State Новый документ из состояния Set History Limit… Задать предел истории… History States Состояния истории # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1854,6 +1864,9 @@ file-filter Templates Шаблоны Live Paint Selection Tool Инструмент «Выделение быстрой заливки» Live Shape Properties Свойства динамической фигуры Load Color Profile… Загрузить профиль цвета… + Load Character Styles… Загрузить стили символов… + Load Paragraph Styles… Загрузить стили абзацев… + Load All Styles… Загрузить все стили… Remove Color Profile… Удалить профиль цвета… Load Image into Clipboard Загрузить изображение в буфер обмена Load Metafile into Clipboard Загрузить метафайл в буфер обмена @@ -2723,6 +2736,14 @@ digits Malayalam Малаялам Relink Восстановить связь Relink Linked Files to the Document Восстановить связи связанных файлов с документом Relink to Folder… Восстановить связь с папкой… + Each Link Separately Каждую связь отдельно + Selected Instances Выбранные экземпляры + All Instances Все экземпляры + Missing Links from This Folder Отсутствующие связи из этой папки +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не является одним из each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each и paths задаются вместе: paths перечисляет записи с ids и path вместо ids, path и folder +@msg paths entry {i} needs path and ids (object ids no other entry has) записи {i} в paths нужны path и ids (идентификаторы объектов, которых нет в других записях) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder принимает path: в папке этого файла ищутся остальные отсутствующие связи Relink… Восстановить связь… Find Linked Files Найти связанные файлы Find Linked Files in Folder… Найти связанные файлы в папке… @@ -2767,6 +2788,7 @@ digits Malayalam Малаялам Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замените его другим файлом или игнорируйте: его изображения продолжат показывать предварительный просмотр, сохранённый с документом. Replace Spine Заменить остов Replace Symbol Заменить символ + Reset Transformation Сбросить трансформацию Replace With Font Заменить шрифтом Replace With Font From Заменить шрифтом из System Система @@ -2783,6 +2805,8 @@ digits Malayalam Малаялам Reset OpenType Features Сбросить функции OpenType Reset Panel Сбросить панель Reset Preferences Сбросить настройки + Search Preferences Поиск в настройках + No settings found Настройки не найдены Reset Profiles Сбросить профили Rotate View Повернуть вид Reset Rotate View Сбросить поворот вида @@ -2838,6 +2862,7 @@ digits Malayalam Малаялам Rotate Tool Инструмент «Поворот» Rotate View Tool Инструмент «Поворот вида» Trackpad Gesture to Rotate View Поворачивать вид жестом трекпада + Hold a Tool Shortcut to Use the Tool Temporarily Временно использовать инструмент, удерживая его клавишу Rotate… Повернуть… Rotation Поворот Roughen… Огрубление… @@ -3279,6 +3304,7 @@ digits Malayalam Малаялам Spaces Пробелы Spacing Guides Направляющие интервалов Invoke Distance Guides Вызывать направляющие расстояний + Snap to Last Location Привязать к последнему положению Spacing: Интервал: Spanish Испанский Specified Distance Заданное расстояние @@ -3968,6 +3994,9 @@ axis Both Оба Put back in the toolbar Вернуть на панель инструментов Float Panel Сделать панель плавающей Dock Panel Прикрепить панель + Dock to Top Прикрепить сверху + Dock to Bottom Прикрепить снизу + Dock Control Bar Прикрепить панель управления Drag to float the toolbar Перетащите, чтобы сделать панель инструментов плавающей Drag to the window's left edge to dock Перетащите к левому краю окна для прикрепления Float Tool Group Сделать группу инструментов плавающей @@ -4415,6 +4444,7 @@ axis Both Оба @msg nothing to save: the document has no visible art нечего сохранять: в документе нет видимого рисунка @msg nothing to undo нечего отменять @msg no history to clear нет истории для очистки +@msg no history state {v} нет состояния истории {v} @msg nothing to warp нечего деформировать @msg nothing to wrap нечего обтекать @msg object {0} cannot have children объект {0} не может иметь потомков @@ -4778,6 +4808,7 @@ axis Both Оба Exit Выйти @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — это ссылка на шаг {r}, которую разрешают только `run` и `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` должен быть массивом идентификаторов объектов, а не {v} +@msg `colors` must be an array of colors, not {v} `colors` должен быть массивом цветов, а не {v} @msg `corners` must be an array of anchor indices, not {v} `corners` должен быть массивом индексов опорных точек, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: в результате шага {i} нет такого значения (чтобы написать сам $, введите $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед этим шагом нет шага {i} (шаги считаются с 0; чтобы написать сам $, введите $$) @@ -5412,6 +5443,11 @@ axis Y Axis Ось Y Keep data of its own with the document Хранить свои данные в документе Change how the objects its effect is applied to are drawn Изменять то, как рисуются объекты, к которым применён его эффект Write the document as a file in its own format Записывать документ в файл собственного формата + Show its progress, see Cancel and write log lines Показывать свой ход выполнения, замечать отмену и записывать строки журнала +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} разрешению `progress` нужен уровень API модулей {PROGRESS_API}: укажите `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль импортирует `env.{name}` с другим типом, чем у хоста: у хоста тип {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль импортирует `env.{name}`, который модуль может использовать только на уровне API модулей {PROGRESS_API} (`api` {PROGRESS_API}) и с разрешением `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль импортирует `{from}.{name}`, который хост не предоставляет: модуль может импортировать только `env.vc_progress`, `env.vc_cancelled` и `env.vc_log` (уровень API модулей 2), без WASI, файлов и сети @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` равно {_1}; этот хост поддерживает уровни API модулей от 1 до {API_LEVEL} @msg `grant` must be a list of permission names `grant` должен быть списком имён разрешений @msg `grant` names an unknown permission `{shown}` `grant` называет неизвестное разрешение `{shown}` @@ -6249,6 +6285,7 @@ breakText Glyphs Глифы Gap 5 Зазор 5 @msg {_1}: they came in as the page's art {_1}: импортированы как графика страницы +@msg warped text objects that came in as drawn art, not as editable type: {_1} деформированные текстовые объекты, импортированные как графика, а не как редактируемый текст: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} объекты, которые отрисовывались иначе, чем собственная страница файла, импортированы как графика страницы: {_1} @msg You must choose a side pattern. Выберите боковой узор. diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 06d3ea547..ee1822273 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -72,6 +72,11 @@ 3 Color Job… 3-färgsjobb… 30° Angles 30°-vinklar 3D and Materials 3D och material + Plane Plan + Extrude Extrudera + Revolve Rotera runt axel + Inflate Blås upp + Select objects, then pick an object type to give them a 3D effect. Markera objekt och välj sedan en objekttyp för att ge dem en 3D-effekt. 3D Ribbon 3D-band 45° Angles 45°-vinklar 60° Angles 60°-vinklar @@ -273,6 +278,8 @@ font-technology Variable Variabla Show Cross Hairs Visa hårkors Show Video Safe Areas Visa videosäkra områden Video Ruler Pixel Aspect Ratio: Pixelproportion för videolinjaler: + Fade Region Outside Artboard Tona ned området utanför ritytan + Update While Dragging Uppdatera vid dragning Hide Video Rulers Dölj videolinjaler Background Contents Bakgrundsinnehåll Background Contents: Bakgrundsinnehåll: @@ -1337,6 +1344,8 @@ font-technology Variable Variabla Grays Gråtoner Grays and Neutrals Gråtoner och neutrala toner Grayscale Gråskala + High Color Många färger + Low Color Få färger Grayscale images Gråskalebilder Greek & Cyrillic Grekiska och kyrilliska Green Grön @@ -1409,6 +1418,7 @@ font-technology Variable Variabla Step Backward Stega bakåt Step Forward Stega framåt Clear History Rensa historik + New Document from State Nytt dokument från tillstånd Set History Limit… Ange historikgräns… History States Historiktillstånd # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1769,6 +1779,9 @@ font-technology Variable Variabla Live Paint Selection Tool Markeringsverktyg för direktmålning Live Shape Properties Egenskaper för direktform Load Color Profile… Läs in färgprofil… + Load Character Styles… Läs in teckenstilar… + Load Paragraph Styles… Läs in styckestilar… + Load All Styles… Läs in alla stilar… Remove Color Profile… Ta bort färgprofil… Load Image into Clipboard Läs in bild till urklipp Load Metafile into Clipboard Läs in metafil till urklipp @@ -2631,6 +2644,14 @@ digits Malayalam Malayalam Relink Länka om Relink Linked Files to the Document Länka om länkade filer till dokumentet Relink to Folder… Länka om till mapp… + Each Link Separately Varje länk separat + Selected Instances Markerade instanser + All Instances Alla instanser + Missing Links from This Folder Saknade länkar från den här mappen +@msg scope {v} is not each|selected|all|missingInFolder scope {v} är inte each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each och paths hör ihop: paths listar poster med ids och path i stället för ids, path och folder +@msg paths entry {i} needs path and ids (object ids no other entry has) post {i} i paths behöver path och ids (objekt-id:n som ingen annan post har) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder tar path: de andra saknade länkarna söks i den filens mapp Relink… Länka om… Find Linked Files Sök länkade filer Find Linked Files in Folder… Sök länkade filer i mapp… @@ -2675,6 +2696,7 @@ digits Malayalam Malayalam Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Ersätt den med en annan fil eller ignorera den: dess bilder fortsätter att visa förhandsgranskningen som sparats med dokumentet. Replace Spine Ersätt ryggrad Replace Symbol Ersätt symbol + Reset Transformation Återställ transformering Replace With Font Ersätt med typsnitt Replace With Font From Ersätt med typsnitt från System System @@ -2691,6 +2713,8 @@ digits Malayalam Malayalam Reset OpenType Features Återställ OpenType-funktioner Reset Panel Återställ panel Reset Preferences Återställ inställningar + Search Preferences Sök i inställningar + No settings found Inga inställningar hittades Reset Profiles Återställ profiler Reset Rotate View Återställ Rotera vy Rotate View to Selection Rotera vyn efter markeringen @@ -2745,6 +2769,7 @@ digits Malayalam Malayalam Rotate Tool Rotationsverktyg Rotate View Tool Verktyget Rotera vy Trackpad Gesture to Rotate View Rotera vyn med en gest på styrplattan + Hold a Tool Shortcut to Use the Tool Temporarily Håll ned ett verktygs kortkommando för att använda verktyget tillfälligt Rotate… Rotera… Rotation Rotation Roughen… Ojämn… @@ -3170,6 +3195,7 @@ digits Malayalam Malayalam Spaces Blanksteg Spacing Guides Avståndsstödlinjer Invoke Distance Guides Anropa mätstödlinjer för avstånd + Snap to Last Location Fäst mot senaste plats Spacing: Avstånd: Spanish Spanska Specified Distance Angivet avstånd @@ -3848,6 +3874,9 @@ axis Both Båda Put back in the toolbar Lägg tillbaka i verktygsfältet Float Panel Gör panelen flytande Dock Panel Docka panel + Dock to Top Docka överst + Dock to Bottom Docka nederst + Dock Control Bar Docka kontrollfältet Drag to float the toolbar Dra för att göra verktygsfältet flytande Drag to the window's left edge to dock Dra till fönstrets vänsterkant för att docka Float Tool Group Gör verktygsgruppen flytande @@ -4283,6 +4312,7 @@ axis Both Båda @msg nothing to save: the document has no visible art inget att spara: dokumentet har ingen synlig grafik @msg nothing to undo inget att ångra @msg no history to clear ingen historik att rensa +@msg no history state {v} inget historiktillstånd {v} @msg nothing to warp inget att böja @msg nothing to wrap inget att radbryta @msg object {0} cannot have children objekt {0} kan inte ha underordnade objekt @@ -4656,6 +4686,7 @@ axis Both Båda ICF Box ICF-ruta @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` är stegreferensen {r}, som bara `run` och `command_batch` löser upp @msg `ids` must be an array of object ids, not {v} `ids` måste vara en array med objekt-id:n, inte {v} +@msg `colors` must be an array of colors, not {v} `colors` måste vara en array med färger, inte {v} @msg `corners` must be an array of anchor indices, not {v} `corners` måste vara en array med index för ankarpunkter, inte {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: resultatet från steg {i} har inget sådant värde (skriv $$ för ett bokstavligt $) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: det finns inget steg {i} före det här (stegen räknas från 0; skriv $$ för ett bokstavligt $) @@ -5824,6 +5855,11 @@ axis Y Axis Y-axel Keep data of its own with the document Spara egna data med dokumentet Change how the objects its effect is applied to are drawn Ändra hur objekten som dess effekt används på ritas Write the document as a file in its own format Skriva dokumentet som en fil i sitt eget format + Show its progress, see Cancel and write log lines Visa sitt förlopp, se när det avbryts och skriva loggrader +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} behörigheten `progress` kräver plugin-API-nivå {PROGRESS_API}: ange `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modulen importerar `env.{name}` med en annan typ än värdens: värdens typ är {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modulen importerar `env.{name}`, som ett plugin bara får använda på plugin-API-nivå {PROGRESS_API} (`api` {PROGRESS_API}) och med behörigheten `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modulen importerar `{from}.{name}`, som värden inte erbjuder: ett plugin får bara importera `env.vc_progress`, `env.vc_cancelled` och `env.vc_log` (plugin-API-nivå 2), och ingen WASI, inga filer eller nätverk @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` är {_1}; den här värden stöder plugin-API-nivå 1 till {API_LEVEL} @msg `grant` must be a list of permission names `grant` måste vara en lista med behörighetsnamn @msg `grant` names an unknown permission `{shown}` `grant` anger en okänd behörighet `{shown}` @@ -6324,6 +6360,7 @@ breakText Glyphs Glyfer Gap 5 Mellanrum 5 @msg {_1}: they came in as the page's art {_1}: de importerades som sidans bild +@msg warped text objects that came in as drawn art, not as editable type: {_1} förvrängda textobjekt som importerades som bild och inte som redigerbar text: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} objekt som ritades annorlunda än filens egen sida importerades som sidans bild: {_1} @msg You must choose a side pattern. Du måste välja ett sidomönster. @@ -7736,7 +7773,6 @@ digits Hindi Hindi @msg the library has no item `{other}` biblioteket har inget objekt `{other}` @msg the library holds {MAX_GROUPS} groups already biblioteket innehåller redan {MAX_GROUPS} grupper @msg the library holds {MAX_ITEMS} items of this kind already biblioteket innehåller redan {MAX_ITEMS} objekt av den här typen -@msg the module imports `{_1}.{_2}`, but plug-ins get no host imports (no WASI, files or network) modulen importerar `{_1}.{_2}`, men plugins får inga värdimporter (ingen WASI, inga filer eller nätverk) @msg the module must export its linear memory as `memory` modulen måste exportera sitt linjära minne som `memory` @msg the name is empty namnet är tomt @msg the object has no fill, stroke or effect objektet har ingen fyllning, kontur eller effekt diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 008f6924a..ed95d4e8e 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -1606,6 +1606,19 @@ fn messages_with_a_count_take_the_plural_form() { assert_eq!(message(cs, "Packaged 21 files"), "Zabaleno 21 souborů"); } +/// The import's note for warped type that opened as drawn art is translated in every complete +/// catalog. It is built from a constant, which the scan of message literals doesn't see. +#[test] +fn warped_type_note_is_translated() { + let note = "warped text objects that came in as drawn art, not as editable type: 3"; + for code in COMPLETE_MESSAGES { + let lang = Lang::from_code(code).expect("registered"); + let shown = message(lang, note); + assert_ne!(shown, note, "{code}"); + assert!(shown.contains('3'), "{code}: {shown}"); + } +} + /// The Gamut Check status line names a count of 1 in the singular and other counts in the plural, /// and every complete catalog translates it, in the form its plural rule picks. #[test] @@ -2117,6 +2130,72 @@ fn translations_keep_the_backticked_names_of_their_english() { assert!(missing.is_empty(), "{} rows drop a backticked name:\n{}", missing.len(), missing.join("\n")); } +/// The spans of `s` between pairs of straight double quotes outside its backticked spans, such as +/// a value a parameter takes; none when those quotes don't pair up. +fn double_quoted(s: &str) -> Vec<&str> { + let outside: Vec<&str> = s.split('`').step_by(2).collect(); + let quotes: usize = outside.iter().map(|part| part.matches('"').count()).sum(); + if !quotes.is_multiple_of(2) { + return Vec::new(); + } + let mut spans = Vec::new(); + let mut open = false; + for part in outside { + for (i, piece) in part.split('"').enumerate() { + if i > 0 { + open = !open; + } + if open && !piece.is_empty() { + spans.push(piece); + } + } + } + spans +} + +/// Whether `text` holds `word` with no letter or digit right before or after it. +fn holds_word(text: &str, word: &str) -> bool { + text.match_indices(word).any(|(at, _)| { + let before = text[..at].chars().next_back().is_none_or(|c| !c.is_alphanumeric()); + let after = text[at + word.len()..].chars().next().is_none_or(|c| !c.is_alphanumeric()); + before && after + }) +} + +#[test] +fn double_quoted_finds_the_spans_between_paired_quotes() { + assert_eq!(double_quoted(r#"span must be "object" or "selection", got {v}"#), ["object", "selection"]); + assert_eq!(double_quoted(r#"`{"a": 1}` is "left""#), ["left"]); + assert_eq!(double_quoted(r#"a lone " quote"#), Vec::<&str>::new()); + assert!(holds_word("“auto” veya “optical”", "auto")); + assert!(!holds_word(r#""content""#, "contents")); + assert!(!holds_word(r#""none""#, "one")); +} + +/// A translated status or error message keeps each value its English text puts in double quotes, +/// spelled as the English spells it, since the value is what a command reads; the quote marks may +/// be the language's own. Menu and control labels quote names of the interface, which translate. +#[test] +fn translated_messages_keep_the_double_quoted_values_of_their_english() { + let mut missing = Vec::new(); + for l in &LANGUAGES { + let (entries, _) = parse_entries(l.source); + for (ctx, src, tr) in entries.iter().filter(|(ctx, _, _)| ctx == "@msg" || ctx == "@msgplural") { + let plural = ctx == "@msgplural"; + let english = if plural { src.rsplit('|').next().unwrap_or(src) } else { src.as_str() }; + let forms: Vec<&str> = if plural { tr.split('|').collect() } else { vec![tr.as_str()] }; + for form in forms { + for value in double_quoted(english) { + if !holds_word(form, value) { + missing.push(format!("{}: \"{value}\" in {src:?} -> {form:?}", l.code)); + } + } + } + } + } + assert!(missing.is_empty(), "{} rows drop a double-quoted value:\n{}", missing.len(), missing.join("\n")); +} + /// Languages whose catalogs translate status and error messages; [`untranslated_report`] lists /// the messages each one lacks. const COMPLETE_MESSAGES: &[&str] = &[ diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 995ed31ca..fb4269e2f 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -69,6 +69,11 @@ 3 Color Job… 3 Renkli İş… 30° Angles 30° Açılar 3D and Materials 3D ve Malzemeler + Plane Düzlem + Extrude Ekstrüzyon + Revolve Döndür + Inflate Şişir + Select objects, then pick an object type to give them a 3D effect. Nesneleri seçin, ardından onlara 3D efekti vermek için bir nesne türü seçin. 3D Ribbon 3D Şerit 45° Angles 45° Açılar 60° Angles 60° Açılar @@ -269,6 +274,8 @@ font-technology Variable Değişken Show Cross Hairs Artı Çizgilerini Göster Show Video Safe Areas Video Güvenli Alanlarını Göster Video Ruler Pixel Aspect Ratio: Video Cetveli Piksel En Boy Oranı: + Fade Region Outside Artboard Çalışma Yüzeyi Dışındaki Bölgeyi Karart + Update While Dragging Sürüklerken Güncelle Hide Video Rulers Video Cetvellerini Gizle Background Contents Arka Plan İçeriği Background Contents: Arkaplan İçeriği: @@ -1301,6 +1308,8 @@ font-technology Variable Değişken Grays Griler Grays and Neutrals Griler ve Nötrler Grayscale Gri tonlamalı + High Color Çok Renk + Low Color Az Renk Grayscale images Gri tonlamalı görüntüler Greek & Cyrillic Yunanca ve Kiril Green Yeşil @@ -1665,6 +1674,9 @@ font-technology Variable Değişken Live Paint Selection Tool Canlı Boyama Seçim Aracı Live Shape Properties Canlı Şekil Özellikleri Load Color Profile… Renk Profilini Yükle… + Load Character Styles… Karakter Stillerini Yükle… + Load Paragraph Styles… Paragraf Stillerini Yükle… + Load All Styles… Tüm Stilleri Yükle… Load Image into Clipboard Resmi Panoya Yükle Load Metafile into Clipboard Meta Dosyasını Panoya Yükle Load PDF into Clipboard PDF'yi Panoya Yükle @@ -2502,6 +2514,14 @@ digits Malayalam Malayalam Relink Yeniden bağla Relink Linked Files to the Document Bağlantılı Dosyaları Belgeye Yeniden Bağlama Relink to Folder… Klasöre Yeniden Bağlan… + Each Link Separately Her Bağlantıyı Ayrı Ayrı + Selected Instances Seçili Örnekler + All Instances Tüm Örnekler + Missing Links from This Folder Bu Klasördeki Eksik Bağlantılar +@msg scope {v} is not each|selected|all|missingInFolder scope {v}, each|selected|all|missingInFolder değerlerinden biri değil +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each ve paths birlikte kullanılır: paths, ids, path ve folder yerine ids ve path içeren girdileri listeler +@msg paths entry {i} needs path and ids (object ids no other entry has) paths girdisi {i} için path ve ids gerekir (başka hiçbir girdide olmayan nesne kimlikleri) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder path alır: diğer eksik bağlantılar o dosyanın klasöründe aranır Relink… Yeniden bağla… Find Linked Files Bağlantılı Dosyaları Bul Find Linked Files in Folder… Klasördeki Bağlantılı Dosyaları Bul… @@ -2545,6 +2565,7 @@ digits Malayalam Malayalam Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Onu başka bir dosyayla değiştirin veya yok sayın: resimleri, belgeyle birlikte kaydedilen önizlemeyi göstermeye devam eder. Replace Spine Omurgayı Değiştir Replace Symbol Sembolü Değiştir + Reset Transformation Dönüşümü Sıfırla Replace With Font Yazı Tipiyle Değiştir Replace With Font From Değiştirilecek Yazı Tipi Kaynağı System Sistem @@ -2561,6 +2582,8 @@ digits Malayalam Malayalam Reset OpenType Features OpenType Özelliklerini Sıfırla Reset Panel Paneli Sıfırla Reset Preferences Tercihleri Sıfırla + Search Preferences Tercihlerde Ara + No settings found Ayar bulunamadı Reset Profiles Profilleri Sıfırla Reset Rotate View Döndürme Görünümünü Sıfırla Rotate View to Selection Görünümü Seçime Döndür @@ -2794,6 +2817,7 @@ digits Malayalam Malayalam Auto Leading: Otomatik Satır Aralığı: Cancel Export Dışa Aktarmayı İptal Et Clear History Geçmişi Temizle + New Document from State Durumdan Yeni Belge Continue in Background Arka Planda Devam Et Delete Light Işığı Sil Desired İstenen @@ -3072,6 +3096,7 @@ digits Malayalam Malayalam Spaces Alanlar Spacing Guides Aralık Kılavuzları Invoke Distance Guides Mesafe Kılavuzlarını Çağır + Snap to Last Location Son Konuma Yasla Spacing: Aralık: Spanish İspanyolca Specified Distance Belirtilen Mesafe @@ -3736,12 +3761,15 @@ axis Both Her İkisi Put back in the toolbar Araç çubuğuna geri yerleştir Float Panel Paneli Yüzer Yap Dock Panel Bağlantı İstasyonu Paneli + Dock to Top Üste yerleştir + Dock to Bottom Alta yerleştir + Dock Control Bar Kontrol çubuğunu yerleştir Drag to float the toolbar Araç çubuğunu ayırmak için sürükleyin Drag to the window's left edge to dock Sabitlemek için pencerenin sol kenarına sürükleyin Float Tool Group Araç Grubunu Yüzer Yap @msg no library `{id}` `{id}` kütüphanesi yok @msg select some type first önce bir tür seçin -@msg kerning must be a number, "auto", or "optical" Karakter aralığı sayı, “auto” veya “optical” olmalıdır +@msg kerning must be a number, "auto", or "optical" kerning bir sayı, “auto” veya “optical” olmalıdır @msg the selection has no gradient fill seçimin degrade dolgusu yok @msg the selection has no gradient stroke seçimin degrade konturu yok @msg the selection has no pattern fill seçimin desen dolgusu yok @@ -3936,9 +3964,9 @@ axis Both Her İkisi @msg too many changed shortcuts değiştirilen kısayol sayısı çok fazla @msg bad stream length Kötü akış uzunluğu @msg bad trailer kötü römork -@msg balance must be between 0 and 100 bakiye 0 ile 100 arasında olmalıdır +@msg balance must be between 0 and 100 balance 0 ile 100 arasında olmalıdır @msg batch step {_1} (`{id}`) failed: {e} toplu adım {_1} (`{id}`) başarısız oldu: {e} -@msg brightness must be dark|mediumDark|mediumLight|light parlaklık koyu|ortaKoyu|ortaAçık|açık olmalıdır +@msg brightness must be dark|mediumDark|mediumLight|light brightness dark|mediumDark|mediumLight|light olmalıdır @msg can't encode the document: {0} belge kodlanamıyor: {0} @msg can't parse shortcut `{c}` `{c}` kısayolu ayrıştırılamıyor @msg can't parse shortcut `{s}` for `{k}` `{k}` için `{s}` kısayolu ayrıştırılamıyor @@ -3963,7 +3991,7 @@ axis Both Her İkisi @msg compound paths can only contain paths bileşik yollar yalnızca yollar içerebilir @msg compressed (RLE) bitmaps can't be read yet sıkıştırılmış (RLE) bitmapler henüz okunamıyor @msg damaged hasarlı -@msg decimals must be {_1}–{_2} (got {_3}) ondalık sayılar {_1}–{_2} olmalıdır ({_3}'yi aldı) +@msg decimals must be {_1}–{_2} (got {_3}) decimals {_1}–{_2} olmalıdır ({_3}'yi aldı) @msg divide into 1 to {MAX_PIECES} slices 1 ila {MAX_PIECES} dilimlerine bölün @msg effect {k} is `{_1}`, not `{id}` {k} efekti `{_1}`, `{id}` değil @msg empty brush stroke boş fırça darbesi @@ -3994,7 +4022,7 @@ axis Both Her İkisi @msg its Windows metafile preview was opened instead, which may simplify the art bunun yerine Windows meta dosyası önizlemesi açıldı, bu da işi basitleştirebilir @msg its preview was placed instead, at screen resolution bunun yerine önizlemesi ekran çözünürlüğüne yerleştirildi @msg join needs open paths katılmak için açık yollara ihtiyaç var -@msg lang must be one of {_1} dil {_1}'den biri olmalıdır +@msg lang must be one of {_1} lang şunlardan biri olmalıdır: {_1} @msg layers can't be wrap objects katmanlar sarma nesneleri olamaz @msg line {line}: value for `{var}` must be true or false satır {line}: `{var}` değeri true veya false olmalıdır @msg line {line}: value for `{var}` is longer than {MAX_GRAPH_CSV} bytes satır {line}: `{var}` değeri {MAX_GRAPH_CSV} bayttan uzun @@ -4148,9 +4176,9 @@ axis Both Her İkisi @msg replace: the object to replace was changed or deleted değiştir: değiştirilecek nesne değiştirildi veya silindi @msg pattern missing desen eksik @msg patterns nested too deeply are filled with mid-grey çok derin iç içe geçmiş desenler orta gri renkle dolu -@msg percent must be above 0 and at most 6400, not {p} yüzde {p} değil, 0'ın üzerinde ve en fazla 6400 olmalıdır +@msg percent must be above 0 and at most 6400, not {p} percent 0'ın üzerinde ve en fazla 6400 olmalıdır, {p} değil @msg pick a colour, gradient or pattern to save as a swatch renk örneği olarak kaydetmek için bir renk, degrade veya desen seçin -@msg plane must be left, right or ground düzlem solda, sağda veya yerde olmalıdır +@msg plane must be left, right or ground plane left, right veya ground olmalıdır @msg plug-in ABI: {0} eklenti ABI: {0} @msg plug-in exceeded its {0} eklenti {0} sınırını aştı @msg plug-in failed: {0} eklenti başarısız oldu: {0} @@ -4169,8 +4197,8 @@ axis Both Her İkisi @msg result would exceed the canvas (coordinates out of range) sonuç tuvali aşacaktır (koordinatlar aralık dışındadır) @msg objects can't be nested more than 32 deep nesneler 32'den fazla derinliğe yerleştirilemez @msg the SVG's elements are nested more than {0} deep SVG'nin öğeleri {0} derinliğinden daha fazla yuvalanmıştır -@msg scale must be a number from {_1} to {_2}, not {_3} ölçek {_3} değil, {_1} ile {_2} arasında bir sayı olmalıdır -@msg scale must be a number from {_1} to {_2}, not {s} ölçek {s} değil, {_1} ile {_2} arasında bir sayı olmalıdır +@msg scale must be a number from {_1} to {_2}, not {_3} scale {_1} ile {_2} arasında bir sayı olmalıdır, {_3} değil +@msg scale must be a number from {_1} to {_2}, not {s} scale {_1} ile {_2} arasında bir sayı olmalıdır, {s} değil @msg select (or target) objects whose appearance isn't basic Görünümü temel olmayan nesneleri seçin (veya hedefleyin) @msg select a Live Paint group bir Canlı Boyama grubu seçin @msg select a blend bir karışım seç @@ -4384,12 +4412,12 @@ axis Both Her İkisi @msg The graphics were lost and couldn't be restarted. Grafikler kayboldu ve yeniden başlatılamadı. @msg Your unsaved changes are kept in this browser: reload the page to get them back. Kaydedilmemiş değişiklikleriniz bu tarayıcıda tutuluyor: onları geri almak için sayfayı yeniden yükleyin. @msg Reload the page to go on (changes not saved yet may be lost). Devam etmek için sayfayı yeniden yükleyin (henüz kaydedilmemiş değişiklikler kaybolabilir). -@msg tool (a tool id) is required araç (araç kimliği) gerekli +@msg tool (a tool id) is required tool (bir araç kimliği) gerekli @msg {tool} isn't in a toolbar group of several tools {tool} çeşitli araçlardan oluşan bir araç çubuğu grubunda değil -@msg onto must name a panel floating in another group üzerine başka bir grupta yüzen bir panel adı verilmelidir -@msg below and above must name a panel floating in another group alt ve üst, başka bir grupta yüzen bir panele ad vermelidir -@msg give only one of onto, below and above üstüne, altına ve üstüne yalnızca birini verin -@msg column must be true or a number from 1 sütun doğru veya 1'den bir sayı olmalıdır +@msg onto must name a panel floating in another group onto başka bir grupta yüzen bir paneli adlandırmalıdır +@msg below and above must name a panel floating in another group below ve above başka bir grupta yüzen bir paneli adlandırmalıdır +@msg give only one of onto, below and above onto, below ve above değerlerinden yalnızca birini verin +@msg column must be true or a number from 1 column true veya 1'den başlayan bir sayı olmalıdır @msg x and y must both be numbers x ve y'nin her ikisi de sayı olmalıdır @msg "{0}" is not a colour: write it as #rrggbb "{0}" bir renk değildir: #rrggbb olarak yazın @msg this isn't a flat-colour logo ({_1}% of its solid areas match a few flat colours), so Flat Logo mode would turn its gradients into patches: use Color mode instead bu düz renkli bir logo değildir (düz alanların %{_1}'si birkaç düz renkle eşleşir), dolayısıyla Düz Logo modu degradelerini yamalara dönüştürür: bunun yerine Renk modunu kullanın @@ -4489,6 +4517,7 @@ axis Both Her İkisi Em Box Em Kutusu x-Height x-Yüksekliği ICF Box ICF Kutusu +@msg `colors` must be an array of colors, not {v} `colors` {v} değil, bir renk dizisi olmalıdır @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`, yalnızca `run` ve `command_batch` komutlarının çözümlediği {r} adım referansıdır @msg `ids` must be an array of object ids, not {v} `ids` bir nesne kimlikleri dizisi olmalıdır, {v} değil @msg `corners` must be an array of anchor indices, not {v} `corners` bir bağlantı noktası indeksleri dizisi olmalıdır, {v} değil @@ -4619,19 +4648,19 @@ effect Soft Yumuşak Resize panels Panelleri yeniden boyutlandır Resize panel window Panel penceresini yeniden boyutlandır @msg Invalid panel layout; restored the default dock Geçersiz panel düzeni; varsayılan dock'u geri yükledim -@msg action is required eylem gerekli -@msg anchor must name a registered panel çapa kayıtlı bir panele isim vermelidir -@msg before must name a panel önce bir panele isim vermeliyim +@msg action is required action gerekli +@msg anchor must name a registered panel anchor kayıtlı bir paneli adlandırmalıdır +@msg before must name a panel before bir paneli adlandırmalıdır @msg docking action contains an unknown panel or an excessive split path yerleştirme eylemi bilinmeyen bir panel veya aşırı bölünmüş yol içeriyor -@msg group must be true or false grup doğru veya yanlış olmalıdır -@msg move requires a destination panel taşıma bir hedef paneli gerektirir -@msg move requires an existing destination panel taşıma mevcut bir hedef paneli gerektirir -@msg onto must name a floating panel üzerine kayan bir panel adı verilmelidir +@msg group must be true or false group true veya false olmalıdır +@msg move requires a destination panel move bir hedef paneli gerektirir +@msg move requires an existing destination panel move mevcut bir hedef paneli gerektirir +@msg onto must name a floating panel onto yüzen bir paneli adlandırmalıdır @msg panel group is empty panel grubu boş -@msg panel must name a registered panel panel kayıtlı bir panele isim vermelidir +@msg panel must name a registered panel panel kayıtlı bir paneli adlandırmalıdır @msg saved panel layout is invalid; reset the workspace first kayıtlı panel düzeni geçersiz; önce çalışma alanını sıfırlayın -@msg zone must be a string bölge bir dize olmalıdır -@msg zone must be tab, center, left, right, top or bottom bölge sekme, orta, sol, sağ, üst veya alt olmalıdır +@msg zone must be a string zone bir dize olmalıdır +@msg zone must be tab, center, left, right, top or bottom zone tab, center, left, right, top veya bottom olmalıdır @msg {key} must be a finite coordinate {key} sonlu bir koordinat olmalıdır @msg x and y must be provided together x ve y birlikte sağlanmalıdır @msg invalid restored panel group geçersiz geri yüklenen panel grubu @@ -5184,7 +5213,7 @@ theme Appearance Görünüm Screen Mode Controls Ekran Modu Kontrolleri Drawer menu Çekmece menüsü Toolbar: Show Controls Araç Çubuğu: Kontrolleri Göster -@msg control must be fillStroke, coloring, drawMode or screenMode kontrol fillStroke, colouring, DrawMode veya screenMode olmalıdır +@msg control must be fillStroke, coloring, drawMode or screenMode control fillStroke, coloring, drawMode veya screenMode olmalıdır @msg unknown control `{name}`: fillStroke, coloring, drawMode or screenMode bilinmeyen kontrol `{name}`: fillStroke, colouring,drawMode veya screenMode @msg give a fraction `t` from 0 to 1 0'dan 1'e kadar bir `t` kesri verin @msg give the path `id` `id` yolunu ver @@ -5243,7 +5272,7 @@ theme Appearance Görünüm @msg Unknown dimension style Bilinmeyen boyut stili @msg Unknown unit Bilinmeyen birim Close the circular ends of open profiles. Partial sweeps keep their cut sides open. Açık profillerin dairesel uçlarını kapatın. Kısmi süpürmeler kesik taraflarını açık tutar. -@msg span must be "object" or "selection", got {v} yayılma "nesne" veya "seçim" olmalıdır, {v} var +@msg span must be "object" or "selection", got {v} span "object" veya "selection" olmalıdır, {v} değil @msg span: "selection" places a `gradient`, not a swatch span: "selection" bir renk örneği değil, bir `gradient` yerleştirir @msg span: "selection" places a gradient: give `gradient` span: "selection" bir degrade yerleştirir: `gradient` verin @msg span: "selection" places a linear or radial gradient, not a freeform one span: "selection" serbest biçimli bir degrade değil, doğrusal veya radyal bir degrade yerleştirir @@ -5265,8 +5294,8 @@ theme Appearance Görünüm @msg the action has no steps eylemin hiçbir adımı yok @msg the action has no included steps eylemin dahil edilmiş adımı yok @msg no such action step öyle bir eylem adımı yok -@msg included must be true or false "included" true veya false olmalıdır -@msg others must be true or false "others" true veya false olmalıdır +@msg included must be true or false included true veya false olmalıdır +@msg others must be true or false others true veya false olmalıdır Accent Color Vurgu Rengi Amber kehribar Coral Mercan @@ -5419,6 +5448,7 @@ theme Appearance Görünüm The name in the save panel was “{typed}”; VectorCraft added the file type's extension. Replacing the file overwrites its contents. Kayıt panelindeki ad “{typed}” idi; VectorCraft dosya türünün uzantısını ekledi. Dosyayı değiştirmek içeriğinin üzerine yazar. Thread Text Metni Bağla Trackpad Gesture to Rotate View Görünümü Döndürmek İçin İzleme Dörtgeni Hareketi + Hold a Tool Shortcut to Use the Tool Temporarily Aracı Geçici Olarak Kullanmak İçin Kısayolunu Basılı Tutma Typing Yazma Uniformly Scaled Tekdüze Ölçeklenmiş Unlock Variables Değişkenlerin Kilidini Aç @@ -5435,7 +5465,7 @@ theme Appearance Görünüm @msg a spine keeps at least two points bir omurga en az iki nokta barındırır @msg cancelled; nothing was changed iptal edildi; hiçbir şey değiştirilmedi @msg handle must be in or out handle in veya out olmalıdır -@msg index must be ≥ 0 dizin ≥ 0 olmalıdır +@msg index must be ≥ 0 index ≥ 0 olmalıdır @msg library panel `{given}` isn't open `{given}` kütüphane paneli açık değil @msg mask art ends outside a mask dictionary maske çizimi bir maske sözlüğünün dışında bitiyor @msg missing number `{key}` `{key}` sayısı eksik @@ -5460,6 +5490,7 @@ theme Appearance Görünüm @msg its editing data is for a {w} × {h} pt page, not the file's {pw} × {ph} pt page düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {pw} × {ph} pt sayfası için değil @msg its editing data is for a {w} × {h} pt page, the file's {pw} × {ph} pt page at {scale} düzenleme verileri {w} × {h} pt boyutunda bir sayfa içindir, dosyanın {scale} ölçeğindeki {pw} × {ph} pt sayfası @msg no history to clear temizlenecek geçmiş yok +@msg no history state {v} {v} geçmiş durumu yok @msg only http, https and mailto URLs open in the browser, not “{url}” tarayıcıda yalnızca http, https ve mailto URL'leri açılır, “{url}” açılmaz @msg {_1} files are written by file.saveAsTemplate {_1} dosyaları file.saveAsTemplate tarafından yazılır @msg {id} opens another app: only the person at the keyboard can run it {id} başka bir uygulamayı açar: yalnızca klavye başındaki kişi çalıştırabilir @@ -5758,6 +5789,11 @@ axis Y Axis Y ekseni Keep data of its own with the document Kendi verilerini belgeyle birlikte saklama Change how the objects its effect is applied to are drawn Efektinin uygulandığı nesnelerin nasıl çizildiğini değiştirme Write the document as a file in its own format Belgeyi kendi biçiminde bir dosya olarak yazma + Show its progress, see Cancel and write log lines İlerlemesini gösterme, iptali algılama ve günlük satırları yazma +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} `progress` izni için eklenti API düzeyi {PROGRESS_API} gerekir: `api` {PROGRESS_API} verin +@msg the module imports `env.{name}` with another type than the host's: it is {signature} modül `env.{name}` öğesini ana bilgisayarınkinden farklı bir türle içe aktarıyor: ana bilgisayarın türü {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` modül `env.{name}` öğesini içe aktarıyor; bir eklenti bunu yalnızca eklenti API düzeyi {PROGRESS_API} (`api` {PROGRESS_API}) ile ve `progress` izniyle kullanabilir +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network modül, ana bilgisayarın sunmadığı `{from}.{name}` öğesini içe aktarıyor: bir eklenti yalnızca `env.vc_progress`, `env.vc_cancelled` ve `env.vc_log` öğelerini (eklenti API düzeyi 2) içe aktarabilir; WASI, dosya veya ağ kullanamaz @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` değeri {_1}; bu ana bilgisayar 1 ile {API_LEVEL} arasındaki eklenti API düzeylerini destekler @msg `grant` must be a list of permission names `grant` izin adlarından oluşan bir liste olmalıdır @msg `grant` names an unknown permission `{shown}` `grant` bilinmeyen bir izin adlandırıyor: `{shown}` @@ -6229,6 +6265,7 @@ breakText Glyphs Glifler Gap 5 Boşluk 5 @msg {_1}: they came in as the page's art {_1}: sayfanın çizimi olarak içe aktarıldılar +@msg warped text objects that came in as drawn art, not as editable type: {_1} düzenlenebilir metin yerine çizim olarak içe aktarılan Çözgü uygulanmış metin nesneleri: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} dosyanın kendi sayfasından farklı çizilen nesneler sayfanın çizimi olarak içe aktarıldı: {_1} @msg You must choose a side pattern. Bir yan desen seçmelisiniz. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 5044fe895..ac74a7404 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -78,6 +78,11 @@ 3 Color Job… Завдання з 3 кольорами… 30° Angles Кути 30° 3D and Materials 3D та матеріали + Plane Площина + Extrude Видавлювання + Revolve Обертання + Inflate Надування + Select objects, then pick an object type to give them a 3D effect. Виділіть об’єкти, потім виберіть тип об’єкта, щоб надати їм 3D-ефект. 3D Ribbon 3D-стрічка 45° Angles Кути 45° 60° Angles Кути 60° @@ -278,6 +283,8 @@ font-technology Variable Варіативні Show Cross Hairs Показати перехрестя Show Video Safe Areas Показати безпечні зони відео Video Ruler Pixel Aspect Ratio: Співвідношення сторін пікселя відео-лінійок: + Fade Region Outside Artboard Затемнювати ділянку поза монтажною областю + Update While Dragging Оновлювати під час перетягування Hide Video Rulers Приховати відео-лінійки Background Contents Вміст фону Background Contents: Вміст фону: @@ -1384,6 +1391,8 @@ file-filter Templates Шаблони Grays Сірі Grays and Neutrals Сірі та нейтральні Grayscale Відтінки сірого + High Color Багато кольорів + Low Color Мало кольорів Grayscale images Зображення у відтінках сірого Greek & Cyrillic Грецька та кирилиця Green Зелений @@ -1457,6 +1466,7 @@ file-filter Templates Шаблони Step Backward Крок назад Step Forward Крок вперед Clear History Очистити історію + New Document from State Новий документ зі стану Set History Limit… Задати межу історії… History States Стан історії # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1851,6 +1861,9 @@ file-filter Templates Шаблони Live Paint Selection Tool Інструмент «Виділення швидкої заливки» Live Shape Properties Властивості динамічної фігури Load Color Profile… Завантажити профіль кольору… + Load Character Styles… Завантажити стилі символів… + Load Paragraph Styles… Завантажити стилі абзаців… + Load All Styles… Завантажити всі стилі… Remove Color Profile… Видалити профіль кольору… Load Image into Clipboard Завантажити зображення в буфер обміну Load Metafile into Clipboard Завантажити метафайл у буфер обміну @@ -2720,6 +2733,14 @@ digits Malayalam Малаялам Relink Відновити зв’язок Relink Linked Files to the Document Відновити зв’язані файли з документом Relink to Folder… Відновити зв’язок із папкою… + Each Link Separately Кожен зв’язок окремо + Selected Instances Вибрані екземпляри + All Instances Усі екземпляри + Missing Links from This Folder Відсутні зв’язки з цієї папки +@msg scope {v} is not each|selected|all|missingInFolder scope {v} не є одним із each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each і paths задаються разом: paths перелічує записи з ids і path замість ids, path і folder +@msg paths entry {i} needs path and ids (object ids no other entry has) запису {i} у paths потрібні path і ids (ідентифікатори об’єктів, яких немає в інших записах) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder приймає path: у папці цього файлу шукаються інші відсутні зв’язки Relink… Відновити зв’язок… Find Linked Files Знайти пов’язані файли Find Linked Files in Folder… Знайти пов’язані файли в папці… @@ -2764,6 +2785,7 @@ digits Malayalam Малаялам Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Замініть його іншим файлом або ігноруйте: зображення продовжать показувати попередній перегляд, збережений з документом. Replace Spine Замінити вісь Replace Symbol Замінити символ + Reset Transformation Скинути трансформацію Replace With Font Замінити шрифтом Replace With Font From Замінити шрифтом з System Система @@ -2780,6 +2802,8 @@ digits Malayalam Малаялам Reset OpenType Features Скинути функції OpenType Reset Panel Скинути панель Reset Preferences Скинути налаштування + Search Preferences Пошук у налаштуваннях + No settings found Налаштування не знайдено Reset Profiles Скинути профілі Rotate View Повернути вид Reset Rotate View Скинути поворот виду @@ -2835,6 +2859,7 @@ digits Malayalam Малаялам Rotate Tool Інструмент «Поворот» Rotate View Tool Інструмент «Поворот виду» Trackpad Gesture to Rotate View Повертати вид жестом трекпада + Hold a Tool Shortcut to Use the Tool Temporarily Тимчасово використовувати інструмент, утримуючи його клавішу Rotate… Повернути… Rotation Поворот Roughen… Надати нерівності… @@ -3277,6 +3302,7 @@ effect Points Точки Spaces Пробіли Spacing Guides Напрямні інтервалів Invoke Distance Guides Викликати напрямні відстаней + Snap to Last Location Прив’язати до останнього розташування Spacing: Інтервал: Spanish Іспанська Specified Distance Задана відстань @@ -3962,6 +3988,9 @@ axis Both Обидві Put back in the toolbar Повернути на панель інструментів Float Panel Від’єднати панель Dock Panel Прикріпити панель + Dock to Top Прикріпити вгорі + Dock to Bottom Прикріпити внизу + Dock Control Bar Прикріпити панель керування Drag to float the toolbar Перетягніть, щоб від’єднати панель інструментів Drag to the window's left edge to dock Перетягніть до лівого краю вікна для прикріплення Float Tool Group Від’єднати групу інструментів @@ -4404,6 +4433,7 @@ axis Both Обидві @msg nothing to save: the document has no visible art нічого зберігати: у документі немає видимого малюнка @msg nothing to undo нічого скасовувати @msg no history to clear немає історії для очищення +@msg no history state {v} немає стану історії {v} @msg nothing to warp нічого деформувати @msg nothing to wrap нічого обтікати @msg object {0} cannot have children об’єкт {0} не може мати нащадків @@ -4765,6 +4795,7 @@ axis Both Обидві Exit Вийти @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` — це посилання на крок {r}, яке розв’язують лише `run` і `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` має бути масивом ідентифікаторів об’єктів, а не {v} +@msg `colors` must be an array of colors, not {v} `colors` має бути масивом кольорів, а не {v} @msg `corners` must be an array of anchor indices, not {v} `corners` має бути масивом індексів опорних точок, а не {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`: у результаті кроку {i} немає такого значення (щоб написати сам $, введіть $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`: перед цим кроком немає кроку {i} (кроки рахуються з 0; щоб написати сам $, введіть $$) @@ -5397,6 +5428,11 @@ axis Y Axis Вісь Y Keep data of its own with the document Зберігати власні дані в документі Change how the objects its effect is applied to are drawn Змінювати те, як малюються об’єкти, до яких застосовано його ефект Write the document as a file in its own format Записувати документ у файл власного формату + Show its progress, see Cancel and write log lines Показувати свій поступ, помічати скасування і записувати рядки журналу +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} дозволу `progress` потрібен рівень API модулів {PROGRESS_API}: укажіть `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} модуль імпортує `env.{name}` з іншим типом, ніж у хоста: у хоста тип {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` модуль імпортує `env.{name}`, який модуль може використовувати лише на рівні API модулів {PROGRESS_API} (`api` {PROGRESS_API}) і з дозволом `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network модуль імпортує `{from}.{name}`, який хост не надає: модуль може імпортувати лише `env.vc_progress`, `env.vc_cancelled` і `env.vc_log` (рівень API модулів 2), без WASI, файлів і мережі @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` дорівнює {_1}; цей хост підтримує рівні API модулів від 1 до {API_LEVEL} @msg `grant` must be a list of permission names `grant` має бути списком назв дозволів @msg `grant` names an unknown permission `{shown}` `grant` називає невідомий дозвіл `{shown}` @@ -5872,6 +5908,7 @@ breakText Glyphs Гліфи Gap 5 Проміжок 5 @msg {_1}: they came in as the page's art {_1}: їх імпортовано як графіку сторінки +@msg warped text objects that came in as drawn art, not as editable type: {_1} деформовані текстові об’єкти, імпортовані як графіка, а не як редагований текст: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} об’єкти, що малювалися інакше, ніж власна сторінка файлу, імпортовано як графіку сторінки: {_1} @msg You must choose a side pattern. Виберіть бічний візерунок. diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 758e6799d..04c59cf6b 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -83,6 +83,11 @@ 3 x 3 Average Trung bình 3 x 3 30° Angles Góc 30° 3D and Materials 3D và vật liệu + Plane Mặt phẳng + Extrude Kéo dài + Revolve Xoay tròn + Inflate Phình + Select objects, then pick an object type to give them a 3D effect. Chọn đối tượng, rồi chọn một kiểu đối tượng để tạo hiệu ứng 3D cho chúng. 3D Ribbon Dải 3D 45° Angles Góc 45° 5 x 5 Average Trung bình 5 x 5 @@ -307,6 +312,8 @@ font-technology Variable Biến thiên Show Cross Hairs Hiện đường chữ thập Show Video Safe Areas Hiện vùng an toàn video Video Ruler Pixel Aspect Ratio: Tỷ lệ khung điểm ảnh của thước video: + Fade Region Outside Artboard Làm tối vùng bên ngoài bảng vẽ + Update While Dragging Cập nhật khi kéo Hide Video Rulers Ẩn thước video Background Contents Nội dung nền Background Contents: Nội dung nền: @@ -963,6 +970,9 @@ font-technology Variable Biến thiên Do Not Move Không di chuyển Do you want to save the changes you made to “{name}”? Bạn có muốn lưu các thay đổi đã thực hiện với “{name}”? Dock Panel Neo bảng + Dock to Top Neo lên trên + Dock to Bottom Neo xuống dưới + Dock Control Bar Neo thanh điều khiển Document Tài liệu Document Color Mode Chế độ màu tài liệu Document Info Thông tin tài liệu @@ -1594,6 +1604,8 @@ file-filter Templates Mẫu Grays Các màu xám Grays and Neutrals Xám và trung tính Grayscale Thang xám + High Color Nhiều màu + Low Color Ít màu Grayscale images Ảnh thang xám Greek & Cyrillic Hy Lạp & Cyrillic Green Xanh lá @@ -1676,6 +1688,7 @@ digits Hindi Hindi Step Backward Lùi một bước Step Forward Tiến một bước Clear History Xóa lịch sử + New Document from State Tài liệu mới từ trạng thái Set History Limit… Đặt giới hạn lịch sử… History States Trạng thái lịch sử # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -2128,6 +2141,9 @@ digits Hindi Hindi Live Paint Selection Tool Công cụ Chọn Tô trực tiếp Live Shape Properties Thuộc tính hình dạng trực tiếp Load Color Profile… Tải hồ sơ màu… + Load Character Styles… Tải kiểu ký tự… + Load Paragraph Styles… Tải kiểu đoạn văn… + Load All Styles… Tải tất cả kiểu… Remove Color Profile… Xóa hồ sơ màu… Load Image into Clipboard Tải ảnh vào bảng tạm Load Metafile into Clipboard Tải metafile vào bảng tạm @@ -2991,6 +3007,14 @@ mark-style Japanese Kiểu Nhật Relink Liên kết lại Relink Linked Files to the Document Liên kết lại các tệp liên kết với tài liệu Relink to Folder… Liên kết lại vào thư mục… + Each Link Separately Từng liên kết riêng + Selected Instances Các phiên bản đã chọn + All Instances Tất cả phiên bản + Missing Links from This Folder Liên kết thiếu từ thư mục này +@msg scope {v} is not each|selected|all|missingInFolder scope {v} không phải each|selected|all|missingInFolder +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each và paths đi cùng nhau: paths liệt kê các mục có ids và path, thay cho ids, path và folder +@msg paths entry {i} needs path and ids (object ids no other entry has) mục {i} của paths cần path và ids (ID đối tượng mà không mục nào khác có) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder nhận path: các liên kết thiếu khác được tìm trong thư mục của tệp đó Relink… Liên kết lại… Reload Plug-ins Tải lại plug-in Remove Gỡ @@ -3035,6 +3059,7 @@ mark-style Japanese Kiểu Nhật Replace it with another file, or ignore it: its images keep showing the preview saved with the document. Thay thế bằng tệp khác hoặc bỏ qua: ảnh của nó vẫn hiển thị bản xem trước đã lưu cùng tài liệu. Replace Spine Thay thế trục Replace Symbol Thay thế ký hiệu + Reset Transformation Đặt lại biến đổi Replace With Font Thay thế bằng phông chữ Replace With Font From Thay thế bằng phông chữ từ System Hệ thống @@ -3057,6 +3082,8 @@ mark-style Japanese Kiểu Nhật Reset OpenType Features Đặt lại tính năng OpenType Reset Panel Đặt lại bảng Reset Preferences Đặt lại tùy chọn + Search Preferences Tìm trong tùy chọn + No settings found Không tìm thấy cài đặt Reset Profiles Đặt lại hồ sơ màu Rotate View Xoay chế độ xem Reset Rotate View Đặt lại xoay chế độ xem @@ -3128,6 +3155,7 @@ mark-style Japanese Kiểu Nhật Rotate Tool Công cụ Xoay Rotate View Tool Công cụ Xoay chế độ xem Trackpad Gesture to Rotate View Xoay chế độ xem bằng cử chỉ trên bàn di chuột + Hold a Tool Shortcut to Use the Tool Temporarily Giữ phím tắt của công cụ để tạm thời dùng công cụ đó Rotate… Xoay… Rotation Góc xoay Rotation relative to: Xoay tương đối với: @@ -3611,6 +3639,7 @@ mark-style Japanese Kiểu Nhật Spacing Khoảng cách Spacing Guides Đường căn khoảng cách Invoke Distance Guides Gọi đường căn đo khoảng cách + Snap to Last Location Hút vào vị trí cuối cùng Spacing: Khoảng cách: Spanish Tiếng Tây Ban Nha Spatter Bắn tung tóe @@ -4235,6 +4264,7 @@ theme Appearance Giao diện @msg `groupColors` must be an array `groupColors` phải là một mảng @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` là tham chiếu bước {r}, chỉ `run` và `command_batch` giải quyết @msg `ids` must be an array of object ids, not {v} `ids` phải là mảng id đối tượng, không phải {v} +@msg `colors` must be an array of colors, not {v} `colors` phải là mảng màu, không phải {v} @msg `library` is a library id or name (see {list}) `library` là id hoặc tên thư viện (xem {list}) @msg `mode` applies to freeform gradients `mode` áp dụng cho chuyển sắc tự do @msg `preserve` must be an object `preserve` phải là một đối tượng @@ -4712,6 +4742,7 @@ theme Appearance Giao diện @msg nothing to save: the document has no visible art không có gì để lưu: tài liệu không có tác phẩm hiển thị @msg nothing to undo không có gì để hoàn tác @msg no history to clear không có lịch sử để xóa +@msg no history state {v} không có trạng thái lịch sử {v} @msg nothing to warp không có gì để biến dạng @msg nothing to wrap không có gì để bọc @msg object ids are non-negative integers, not {v} id đối tượng là số nguyên không âm, không phải {v} @@ -5398,6 +5429,11 @@ axis Y Axis Trục Y Keep data of its own with the document Lưu dữ liệu riêng cùng tài liệu Change how the objects its effect is applied to are drawn Thay đổi cách vẽ các đối tượng được áp dụng hiệu ứng của nó Write the document as a file in its own format Ghi tài liệu thành tệp theo định dạng riêng + Show its progress, see Cancel and write log lines Hiển thị tiến độ, nhận biết yêu cầu hủy và ghi các dòng nhật ký +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} quyền `progress` cần API plug-in cấp {PROGRESS_API}: hãy đặt `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} mô-đun nhập `env.{name}` với kiểu khác với kiểu của máy chủ: kiểu của máy chủ là {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` mô-đun nhập `env.{name}`, thứ mà plug-in chỉ được dùng ở API plug-in cấp {PROGRESS_API} (`api` {PROGRESS_API}) và với quyền `progress` +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network mô-đun nhập `{from}.{name}`, thứ mà máy chủ không cung cấp: plug-in chỉ được nhập `env.vc_progress`, `env.vc_cancelled` và `env.vc_log` (API plug-in cấp 2), không có WASI, tệp hay mạng @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` là {_1}; máy chủ này hỗ trợ các mức API plug-in từ 1 đến {API_LEVEL} @msg `grant` must be a list of permission names `grant` phải là danh sách tên quyền @msg `grant` names an unknown permission `{shown}` `grant` nêu một quyền không xác định `{shown}` @@ -5873,6 +5909,7 @@ breakText Glyphs Glyph Gap 5 Khoảng hở 5 @msg {_1}: they came in as the page's art {_1}: đã được nhập dưới dạng hình vẽ của trang +@msg warped text objects that came in as drawn art, not as editable type: {_1} các đối tượng văn bản biến dạng được nhập dưới dạng hình vẽ, không phải văn bản có thể chỉnh sửa: {_1} @msg objects that drew differently from the file's own page came in as its art: {_1} các đối tượng được vẽ khác với trang riêng của tệp đã được nhập dưới dạng hình vẽ của trang: {_1} @msg You must choose a side pattern. Bạn phải chọn mẫu bên. diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a43d1a88b..188fafbac 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -26,6 +26,11 @@ 3 Color Job… 3 色作业… 30° Angles 30° 角 3D and Materials 3D 与材质 + Plane 平面 + Extrude 突出 + Revolve 回转 + Inflate 膨胀 + Select objects, then pick an object type to give them a 3D effect. 选择对象,然后选取一种对象类型,为其添加 3D 效果。 3D Ribbon 3D 缎带 45° Angles 45° 角 60° Angles 60° 角 @@ -230,6 +235,8 @@ Show Cross Hairs 显示十字线 Show Video Safe Areas 显示视频安全区域 Video Ruler Pixel Aspect Ratio: 视频标尺像素长宽比: + Fade Region Outside Artboard 调暗画板外的区域 + Update While Dragging 拖动时更新 Hide Video Rulers 隐藏视频标尺 Background Contents 背景内容 Background Contents: 背景内容: @@ -1326,6 +1333,8 @@ file-filter Templates 模板 Grays 灰度级数 Grays and Neutrals 灰色和中性色 Grayscale 灰度 + High Color 多色 + Low Color 少色 Flat Logo 扁平徽标 Grayscale images 灰度图像 Greek & Cyrillic 希腊文与西里尔文 @@ -1396,6 +1405,7 @@ file-filter Templates 模板 Step Backward 后退一步 Step Forward 前进一步 Clear History 清除历史记录 + New Document from State 从状态新建文档 Set History Limit… 设置历史记录上限… History States 历史记录步数 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1739,6 +1749,9 @@ file-filter Templates 模板 Live Paint Selection Tool 实时上色选择工具 Live Shape Properties 实时形状属性 Load Color Profile… 载入颜色配置文件… + Load Character Styles… 载入字符样式… + Load Paragraph Styles… 载入段落样式… + Load All Styles… 载入所有样式… Remove Color Profile… 移除颜色配置文件… Load Image into Clipboard 将图像载入剪贴板 Load Metafile into Clipboard 将图元文件载入剪贴板 @@ -2599,6 +2612,14 @@ digits Malayalam 马拉雅拉姆数字 Relink 重新链接 Relink Linked Files to the Document 将链接文件重新链接到文档 Relink to Folder… 重新链接到文件夹… + Each Link Separately 逐个链接 + Selected Instances 所选实例 + All Instances 所有实例 + Missing Links from This Folder 此文件夹中缺失的链接 +@msg scope {v} is not each|selected|all|missingInFolder scope {v} 不是 each|selected|all|missingInFolder 之一 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each 与 paths 需一起使用:paths 列出包含 ids 和 path 的条目,用来代替 ids、path 和 folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 条目 {i} 需要 path 和 ids(其他条目没有的对象 ID) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder 需要 path:将在该文件所在的文件夹中查找其他缺失的链接 Relink… 重新链接… Find Linked Files 查找链接文件 Find Linked Files in Folder… 在文件夹中查找链接文件… @@ -2643,6 +2664,7 @@ digits Malayalam 马拉雅拉姆数字 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 将其替换为其他文件,或忽略:其图像继续显示随文档保存的预览。 Replace Spine 替换脊线 Replace Symbol 替换符号 + Reset Transformation 重置变换 Replace With Font 替换为字体 Replace With Font From 替换字体来源 System 系统 @@ -2659,6 +2681,8 @@ digits Malayalam 马拉雅拉姆数字 Reset OpenType Features 重置 OpenType 特性 Reset Panel 重置面板 Reset Preferences 重置首选项 + Search Preferences 搜索首选项 + No settings found 未找到设置 Reset Profiles 重置配置文件 Reset Rotate View 重置旋转视图 Rotate View to Selection 按所选对象旋转视图 @@ -2712,6 +2736,7 @@ digits Malayalam 马拉雅拉姆数字 Rotate Tool 旋转工具 Rotate View Tool 旋转视图工具 Trackpad Gesture to Rotate View 使用触控板手势旋转视图 + Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快捷键以临时使用该工具 Rotate… 旋转… Rotation 旋转 Roughen… 粗糙效果… @@ -3117,6 +3142,7 @@ digits Malayalam 马拉雅拉姆数字 Spaces 空格 Spacing Guides 间距参考线 Invoke Distance Guides 调用距离参考线 + Snap to Last Location 对齐上一位置 Spacing: 间距: Spanish 西班牙语 Specified Distance 指定距离 @@ -3902,6 +3928,7 @@ imageTrace Outline 轮廓 @msg nothing to redo 没有可重做的操作 @msg nothing to undo 没有可撤销的操作 @msg no history to clear 没有可清除的历史记录 +@msg no history state {v} 没有历史记录状态 {v} @msg Expand Appearance: the selection has a basic appearance 扩展外观:所选对象只有基本外观 @msg select (or target) objects whose appearance isn't basic 请选择 (或定位) 外观不是基本外观的对象 @msg {C}: no effect at index {from} {C}:索引 {from} 处没有效果 @@ -4535,6 +4562,7 @@ imageTrace Outline 轮廓 @msg No linked files to look for 没有要查找的链接文件 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} +@msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} @msg `corners` must be an array of anchor indices, not {v} `corners` 必须是由锚点索引组成的数组,而不是 {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步骤 {i} 的结果中没有此值 (要表示 $ 本身,请写 $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步骤之前没有步骤 {i} (步骤从 0 开始计数;要表示 $ 本身,请写 $$) @@ -5036,6 +5064,9 @@ axis Both 两者 Put back in the toolbar 放回工具栏 Float Panel 浮动面板 Dock Panel 停放面板 + Dock to Top 停放到顶部 + Dock to Bottom 停放到底部 + Dock Control Bar 停放控制栏 Drag to float the toolbar 拖动以浮动工具栏 Drag to the window's left edge to dock 拖到窗口左边缘以停放 Float Tool Group 浮动工具组 @@ -5639,6 +5670,11 @@ axis Y Axis Y 轴 Keep data of its own with the document 在文档中保存自己的数据 Change how the objects its effect is applied to are drawn 更改应用了其效果的对象的绘制方式 Write the document as a file in its own format 将文档写为其自有格式的文件 + Show its progress, see Cancel and write log lines 显示进度、检测取消请求并写入日志行 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 权限 `progress` 需要增效工具 API 级别 {PROGRESS_API}:请设置 `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模块以与宿主不同的类型导入 `env.{name}`:宿主的类型为 {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模块导入了 `env.{name}`,增效工具只有在增效工具 API 级别 {PROGRESS_API}(`api` {PROGRESS_API})且具有权限 `progress` 时才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模块导入了宿主不提供的 `{from}.{name}`:增效工具只能导入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效工具 API 级别 2),不能使用 WASI、文件或网络 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 为 {_1};此主机支持的增效工具 API 级别为 1 到 {API_LEVEL} @msg `grant` must be a list of permission names `grant` 必须是权限名称的列表 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知权限 `{shown}` @@ -6113,6 +6149,7 @@ breakText Glyphs 字形 Gap 5 间隔 5 @msg {_1}: they came in as the page's art {_1}:已作为页面图稿导入 +@msg warped text objects that came in as drawn art, not as editable type: {_1} 作为图稿而非可编辑文字导入的变形文字对象:{_1} @msg objects that drew differently from the file's own page came in as its art: {_1} 与文件自身页面绘制不同的对象已作为页面图稿导入:{_1} @msg You must choose a side pattern. 必须选择侧边图案。 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 863bd31ba..0e9326ec5 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -34,6 +34,11 @@ 3 Color Job… 3 色印刷作業… 30° Angles 30° 角度 3D and Materials 3D 與材質 + Plane 平面 + Extrude 突出 + Revolve 迴轉 + Inflate 膨脹 + Select objects, then pick an object type to give them a 3D effect. 選取物件,然後挑選一種物件類型,為其加上 3D 效果。 3D Ribbon 3D 緞帶 45° Angles 45° 角度 60° Angles 60° 角度 @@ -243,6 +248,8 @@ font-technology Variable 可變 Show Cross Hairs 顯示十字線 Show Video Safe Areas 顯示視訊安全區域 Video Ruler Pixel Aspect Ratio: 視訊尺標像素長寬比: + Fade Region Outside Artboard 調暗工作區域外的區域 + Update While Dragging 拖曳時更新 Hide Video Rulers 隱藏視訊尺標 Background Contents 背景內容 Background Contents: 背景內容: @@ -1354,6 +1361,8 @@ file-filter Templates 範本 Grays 灰色 Grays and Neutrals 灰色與中性色 Grayscale 灰階 + High Color 多色 + Low Color 少色 Flat Logo 扁平標誌 Grayscale images 灰階影像 Greek & Cyrillic 希臘文與西里爾文 @@ -1428,6 +1437,7 @@ file-filter Templates 範本 Step Backward 後退一步 Step Forward 前進一步 Clear History 清除步驟記錄 + New Document from State 從步驟新增文件 Set History Limit… 設定步驟記錄上限… History States 步驟記錄狀態 # History panel: the names of undo steps (`untranslated_report` lists any that are missing). @@ -1779,6 +1789,7 @@ file-filter Templates 範本 @msg nothing to redo 沒有可重做的動作 @msg nothing to undo 沒有可復原的動作 @msg no history to clear 沒有可清除的步驟記錄 +@msg no history state {v} 沒有步驟記錄 {v} @msg Expand Appearance: the selection has a basic appearance 展開外觀:選取範圍只有基本外觀 @msg select (or target) objects whose appearance isn't basic 請選取 (或指定目標) 外觀不是基本外觀的物件 @msg {C}: no effect at index {from} {C}:索引 {from} 處沒有效果 @@ -2413,6 +2424,7 @@ file-filter Templates 範本 @msg No linked files to look for 沒有要尋找的連結檔案 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} +@msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} @msg `corners` must be an array of anchor indices, not {v} `corners` 必須是由錨點索引組成的陣列,而非 {v} @msg `{text}`: step {i}'s result has no such value (write $$ for a literal $) `{text}`:步驟 {i} 的結果中沒有此值 (若要表示 $ 本身,請寫 $$) @msg `{text}`: there is no step {i} before this one (steps count from 0; write $$ for a literal $) `{text}`:此步驟之前沒有步驟 {i} (步驟從 0 起算;若要表示 $ 本身,請寫 $$) @@ -2701,6 +2713,9 @@ file-filter Templates 範本 Live Paint Selection Tool 即時上色選取工具 Live Shape Properties 即時形狀內容 Load Color Profile… 載入色彩描述檔… + Load Character Styles… 載入字元樣式… + Load Paragraph Styles… 載入段落樣式… + Load All Styles… 載入所有樣式… Remove Color Profile… 移除色彩描述檔… Load Image into Clipboard 將影像載入剪貼簿 Load Metafile into Clipboard 將中繼檔載入剪貼簿 @@ -3591,6 +3606,14 @@ digits Malayalam 馬來亞拉姆數字 Relink 重新連結 Relink Linked Files to the Document 將連結檔案重新連結至文件 Relink to Folder… 重新連結至資料夾… + Each Link Separately 逐一連結 + Selected Instances 所選實例 + All Instances 所有實例 + Missing Links from This Folder 此資料夾中遺失的連結 +@msg scope {v} is not each|selected|all|missingInFolder scope {v} 不是 each|selected|all|missingInFolder 其中之一 +@msg scope each and paths go together: paths lists entries of ids and path, in place of ids, path and folder scope each 與 paths 須一起使用:paths 列出含 ids 與 path 的項目,用來取代 ids、path 與 folder +@msg paths entry {i} needs path and ids (object ids no other entry has) paths 項目 {i} 需要 path 與 ids(其他項目沒有的物件 ID) +@msg scope missingInFolder takes path: the folder of that file is searched for the other missing links scope missingInFolder 需要 path:會在該檔案所在的資料夾中尋找其他遺失的連結 Relink… 重新連結… Find Linked Files 尋找連結檔案 Find Linked Files in Folder… 在資料夾中尋找連結檔案… @@ -3635,6 +3658,7 @@ digits Malayalam 馬來亞拉姆數字 Replace it with another file, or ignore it: its images keep showing the preview saved with the document. 以其他檔案取代,或忽略:其影像會繼續顯示隨文件儲存的預覽。 Replace Spine 取代脊線 Replace Symbol 取代符號 + Reset Transformation 重設變形 Replace With Font 取代為字體 Replace With Font From 取代字體來源 System 系統 @@ -3651,6 +3675,8 @@ digits Malayalam 馬來亞拉姆數字 Reset OpenType Features 重設 OpenType 功能 Reset Panel 重設面板 Reset Preferences 重設偏好設定 + Search Preferences 搜尋偏好設定 + No settings found 找不到設定 Reset Profiles 重設描述檔 Rotate View 旋轉檢視 Reset Rotate View 重設旋轉檢視 @@ -3706,6 +3732,7 @@ digits Malayalam 馬來亞拉姆數字 Rotate Tool 旋轉工具 Rotate View Tool 旋轉檢視工具 Trackpad Gesture to Rotate View 使用觸控式軌跡板手勢旋轉檢視 + Hold a Tool Shortcut to Use the Tool Temporarily 按住工具快速鍵以暫時使用該工具 Rotate… 旋轉… Rotation 旋轉 Roughen… 粗糙效果… @@ -4145,6 +4172,7 @@ digits Malayalam 馬來亞拉姆數字 Spaces 空格 Spacing Guides 間距參考線 Invoke Distance Guides 叫用距離參考線 + Snap to Last Location 靠齊上一個位置 Spacing: 間距: Spanish 西班牙文 Specified Distance 指定距離 @@ -4847,6 +4875,9 @@ axis Both 兩者 Put back in the toolbar 放回工具列 Float Panel 浮動面板 Dock Panel 停駐面板 + Dock to Top 停駐到頂部 + Dock to Bottom 停駐到底部 + Dock Control Bar 停駐控制列 Drag to float the toolbar 拖曳以浮動工具列 Drag to the window's left edge to dock 拖曳到視窗左側邊緣以停駐 Float Tool Group 浮動工具組 @@ -5435,6 +5466,11 @@ axis Y Axis Y 軸 Keep data of its own with the document 在文件中保存自己的資料 Change how the objects its effect is applied to are drawn 變更套用其效果的物件的繪製方式 Write the document as a file in its own format 將文件寫成其自有格式的檔案 + Show its progress, see Cancel and write log lines 顯示進度、偵測取消要求並寫入記錄行 +@msg permission `progress` needs plug-in API level {PROGRESS_API}: give `api` {PROGRESS_API} 權限 `progress` 需要增效模組 API 層級 {PROGRESS_API}:請設定 `api` {PROGRESS_API} +@msg the module imports `env.{name}` with another type than the host's: it is {signature} 模組以與主機不同的類型匯入 `env.{name}`:主機的類型為 {signature} +@msg the module imports `env.{name}`, which a plug-in may use only at plug-in API level {PROGRESS_API} (`api` {PROGRESS_API}) and with the permission `progress` 模組匯入了 `env.{name}`,增效模組只有在增效模組 API 層級 {PROGRESS_API}(`api` {PROGRESS_API})且具有權限 `progress` 時才能使用它 +@msg the module imports `{from}.{name}`, which the host doesn't offer: a plug-in may import only `env.vc_progress`, `env.vc_cancelled` and `env.vc_log` (plug-in API level 2), and no WASI, files or network 模組匯入了主機未提供的 `{from}.{name}`:增效模組只能匯入 `env.vc_progress`、`env.vc_cancelled` 和 `env.vc_log`(增效模組 API 層級 2),不能使用 WASI、檔案或網路 @msg `api` is {_1}; this host implements plug-in API levels 1 to {API_LEVEL} `api` 為 {_1};此主機支援的增效模組 API 層級為 1 到 {API_LEVEL} @msg `grant` must be a list of permission names `grant` 必須是權限名稱的清單 @msg `grant` names an unknown permission `{shown}` `grant` 包含未知權限 `{shown}` @@ -5910,6 +5946,7 @@ breakText Glyphs 字符 Gap 5 間隙 5 @msg {_1}: they came in as the page's art {_1}:已作為頁面圖稿匯入 +@msg warped text objects that came in as drawn art, not as editable type: {_1} 作為圖稿而非可編輯文字匯入的彎曲文字物件:{_1} @msg objects that drew differently from the file's own page came in as its art: {_1} 與檔案本身頁面繪製不同的物件已作為頁面圖稿匯入:{_1} @msg You must choose a side pattern. 必須選擇側邊圖樣。 diff --git a/crates/ui-egui/src/io/open.rs b/crates/ui-egui/src/io/open.rs index f5bb617df..bb605de7b 100644 --- a/crates/ui-egui/src/io/open.rs +++ b/crates/ui-egui/src/io/open.rs @@ -175,6 +175,7 @@ fn permission_words(name: &str) -> String { Some(permissions::Permission::EffectRender) => tl!("Change how the objects its effect is applied to are drawn"), Some(permissions::Permission::ExportWrite) => tl!("Write the document as a file in its own format"), Some(permissions::Permission::ImportRead) => tl!("Read files you open in its format and make documents from them"), + Some(permissions::Permission::Progress) => tl!("Show its progress, see Cancel and write log lines"), None => return permissions::display_text(name, permissions::MAX_SHOWN_FIELD), }; words.to_string() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 21c000c12..fb8912455 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -315,6 +315,9 @@ pub struct VectorcraftApp { /// The context, viewport and frame `shortcuts::handle` already took in this host iteration. /// Interleaving root and auxiliary viewport passes cannot make an older frame look new again. pub(crate) shortcuts_frames: Vec, + /// The single-key tool shortcuts held down, the last held last, and the tool each goes back to + /// when it is let go ([`shortcuts::HeldTool`]). + pub(crate) held_tool: Vec, /// A synthetic batch is in the host's raw input and no settled UI pass has taken it yet. synthetic_injected: bool, /// The exact events of [`Self::synthetic_injected`], to remove them if the state they belong diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index 8031a98da..fd3e3c089 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -449,6 +449,18 @@ fn run_ui_part5(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option flag(&mut app.ui.control_bar), + // The Control bar's panel menu and gripper: the edge of the window the bar docks to. + "window.control.dock" => { + let key = "edge"; + let edges = crate::chrome::CONTROL_BAR_EDGES; + match edges.iter().position(|e| s(key).as_deref() == Some(*e)) { + Some(i) => { + app.ui.control_bar_bottom = i == 1; + Ok(json!({ key: edges[i] })) + } + None => Err(format!("`{key}` must be one of {}", edges.join(", "))), + } + } "window.toolbar" => flag(&mut app.ui.toolbar), "window.toolbarColumns" => { let toolbar = match crate::custom_toolbars::target(&app.ui, p) { @@ -745,6 +757,9 @@ fn run_ui_part9(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::text_styles::load(app, "charStyle.load", s("path")), + "ui.loadParaStyles" => crate::panels::text_styles::load(app, "paraStyle.load", s("path")), + "ui.loadAllStyles" => crate::panels::text_styles::load(app, "textStyle.loadAll", s("path")), id if id.starts_with(crate::panels::graphic_styles::USER_SLOT) => match crate::panels::graphic_styles::user_library(app, id) { Some(l) => crate::panels::graphic_styles::open_library(app, &json!({ "library": l.id })), None => Err("no such user library".into()), @@ -837,6 +852,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::links::open_file(app, p, false), "links.reveal" => crate::panels::links::open_file(app, p, true), "ui.placementOptionsDialog" => crate::dialogs::placement_options::open(app, p), + "ui.relinkDialog" => crate::dialogs::relink::open(app, p), "ui.findLinksInFolder" => { crate::dialogs::find_links::find_in_folder_command(app, s("folder"), p.get("ids").and_then(Value::as_array).cloned()) } diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index 963045722..ecaca0c6e 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -59,6 +59,9 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { // a document no item is checked. "view.rotate" => app.view().zip(p.get("angle").and_then(Value::as_f64)).is_some_and(|(view, a)| (view.rotation - a).abs() < 1e-9), "window.control" => app.ui.control_bar, + "window.control.dock" => { + p.get("edge").and_then(Value::as_str) == Some(crate::chrome::CONTROL_BAR_EDGES[usize::from(app.ui.control_bar_bottom)]) + } "window.toolbar" => app.ui.toolbar, "window.toolbarAdvanced" => app.ui.toolbar_advanced, "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) { @@ -157,13 +160,13 @@ pub fn shortcut_of(id: &str) -> Option<&'static str> { /// Commands that would act on the text being typed, held back while an IME composes in the Type /// tool (its marked text isn't committed yet): Undo/Redo — the native menu takes ⌘Z ahead of the /// IME —, the clipboard and the selection. -/// Clear History also waits: it ends the typing session as Undo does. +/// Clear History and New Document from State also wait: they end the typing session as Undo does. pub(crate) fn waits_for_ime(app: &VectorcraftApp, id: &str) -> bool { app.session.tool_composing() && (matches!( id, "edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "select.all" | "select.none" | "select.inverse" - ) || id == "edit.clearHistory" + ) || matches!(id, "edit.clearHistory" | "edit.newDocumentFromState") || id.starts_with("edit.paste")) } @@ -278,6 +281,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "ui.dxfOptionsDialog" => app.session.active().is_some(), "links.editOriginal" | "links.reveal" => selected_image(app, |im| im.link.is_some()) || selected_placed(app), "ui.placementOptionsDialog" => selected_image(app, |_| true) || selected_placed(app), + "ui.relinkDialog" => app.services.pick_open.is_some() && (selected_image(app, |_| true) || selected_placed(app)), "ui.findLinksInFolder" => crate::picks::can(app, &crate::picks::PickRequest::Folder) && app.session.active().is_some(), "ui.packageDialog" | "docInfo.save" => app.session.active().is_some(), "ui.batchDialog" => !app.ui.action_sets.is_empty() && app.ui.action_sets.iter().any(|s| !s.actions.is_empty()), diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index 46d695f16..44e161758 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -630,6 +630,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec)> { ]; let lang = crate::i18n::current(); let mut panels = vec![ + panel("3D and Materials", crate::panels::three_d::ID), panel("Actions", "actions"), panel("Align", "align"), panel("Appearance", "appearance"), diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 5505556d1..debd6c224 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -453,6 +453,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ), ("view.rotateReset", "Reset Rotate View", "Cmd+Shift+1", "{}"), ("window.control", "Control", "", "{}"), + ( + "window.control.dock", + "Dock Control Bar", + "", + "{edge: \"top\"|\"bottom\"} dock the Control bar to the top of the window, under the menu bar, or to its bottom, as the bar's panel menu (Dock to Top, Dock to Bottom) and a drag of its gripper do; kept with the workspace; another edge is an error and changes nothing; returns {edge}", + ), ("window.toolbar", "Tools", "", "{}"), ( "window.toolbarColumns", @@ -963,6 +969,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{names?: [the styles selected in the Graphic Styles panel]} open Save Graphic Style Library (dialog `saveGraphicStyleLibrary`: name, user: save to the user library folder, selectedOnly); OK runs graphicStyle.saveLibrary", ), + ( + "ui.loadCharStyles", + "Load Character Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character styles to the active one (engine: charStyle.load), the Character Styles panel menu's Load Character Styles…", + ), + ( + "ui.loadParaStyles", + "Load Paragraph Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's paragraph styles to the active one (engine: paraStyle.load), the Paragraph Styles panel menu's Load Paragraph Styles…", + ), + ( + "ui.loadAllStyles", + "Load All Styles…", + "", + "{path?} (default: pick a document in an open dialog; cancelling it loads nothing) add that document's character and paragraph styles to the active one (engine: textStyle.loadAll), Load All Styles… in either panel's menu", + ), ( "window.userGraphicStyleLibrary1", "User Graphic Style Library 1", @@ -1241,6 +1265,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{ids?} open Placement Options for images `ids` (default: the selected ones; dialog `placementOptions`: ids, preserve, align, clip, see links.placementOptions); OK runs links.placementOptions", ), + ( + "ui.relinkDialog", + "Relink…", + "", + "{ids?: [image or placed document ids]} open Relink for `ids` (default: the selected ones; dialog `relink`: ids, links ([{name, ids}], one row per linked file), scope (Replace: each|selected (default)|all|missingInFolder, see links.relink), path (picked when empty), paths (with each: the files chosen so far, one per row of links)); OK runs links.relink with them, one undo step, except with each: OK takes path, or picks a file, for the next row of links, and the last row's OK relinks them all. An error when nothing is given or selected", + ), ( "ui.findLinksInFolder", "Find Linked Files in Folder…", diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 41f1901ba..84eaf8791 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -43,6 +43,7 @@ pub mod swatches; pub mod symbols; pub mod tabs; pub mod text_styles; +pub mod three_d; pub mod transform; pub mod transparency; pub mod variables; @@ -179,6 +180,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { links::ID => links::show(app, ui), asset_export::ID => asset_export::show(app, ui), css_properties::ID => css_properties::show(app, ui), + three_d::ID => three_d::show(app, ui), crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui), crate::addons::color::ID => crate::addons::color::show(app, ui), crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui), @@ -242,7 +244,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool } /// The ≡ button drawn into `rect` that opens a panel menu. -fn menu_button(ui: &mut Ui, id: &str, rect: Rect) -> egui::Response { +pub(crate) fn menu_button(ui: &mut Ui, id: &str, rect: Rect) -> egui::Response { let t = Tokens::get(ui.ctx()); let resp = ui.interact(rect, ui.scope_id().with(("panel-menu", id)), Sense::click()); icons::paint(ui, "menu", rect.shrink(1.0), if resp.hovered() { t.text_strong } else { t.text_dim }); @@ -517,19 +519,23 @@ pub(crate) fn label_or_name(s: &str, built_in: bool) -> &str { crate::i18n::label_or_name(crate::i18n::current(), s, built_in) } -/// "Recent Colors" header + a row of chips (the Session's recent colours, which every paint -/// command feeds); returns the one clicked, for the caller to apply. +/// "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. 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")); - let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); + let width = ui.available_width(); + let colors = &app.session.recent_colors; + // Chips are 16 px on a 19 px pitch, inset 3 px, with 2 px kept clear of the right edge. + let per_row = (((width - 21.0) / 19.0).floor() as usize + 1).max(1); + let rows = colors.len().div_ceil(per_row).max(1); + let (r, _) = ui.allocate_exact_size(vec2(width, 3.0 + rows as f32 * 19.0), Sense::hover()); ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside); let mut chosen = None; - for (i, c) in app.session.recent_colors.iter().enumerate() { - let cell = Rect::from_min_size(r.min + vec2(3.0 + i as f32 * 19.0, 3.0), vec2(16.0, 16.0)); - if cell.right() > r.right() - 2.0 { - break; - } + for (i, c) in colors.iter().enumerate() { + let (row, col) = (i / per_row, i % per_row); + let cell = Rect::from_min_size(r.min + vec2(3.0 + col as f32 * 19.0, 3.0 + row as f32 * 19.0), vec2(16.0, 16.0)); let resp = ui.interact(cell, ui.scope_id().with(("recent", i)), Sense::click()); crate::widgets::swatch_tile(ui, cell, &Paint::solid(*c), false, resp.hovered()); if resp.on_hover_text(c.to_hex()).clicked() { diff --git a/crates/ui-egui/src/panels/artboards.rs b/crates/ui-egui/src/panels/artboards.rs index d02f5f028..9790270a4 100644 --- a/crates/ui-egui/src/panels/artboards.rs +++ b/crates/ui-egui/src/panels/artboards.rs @@ -48,10 +48,15 @@ const ART_OPTIONS_WIDTH: f32 = 370.0; /// less, they show only in the Transform link's popover. const INLINE_FIELDS_WIDTH: f32 = 480.0; +/// The room the Control bar needs from the artboard's name field to Move and Scale Artwork with +/// Artboard shown as their boxes; with less, the name field narrows from 120 to 100 points. +const NAME_FIELD_ROOM: f32 = 330.0; + /// The Control bar while the Artboard tool is in use, in place of the object controls: the active -/// artboard's preset and orientation, New Artboard and Delete Artboard, its name, Artboard Options, -/// Rearrange All Artboards and the Transform link (the Transform panel in a popover), which all fit -/// in the narrowest window; then Move and Scale Artwork with Artboard (the boxes alone, with their +/// artboard's preset and orientation, New Artboard and Delete Artboard, its name (in a narrower +/// field when the bar is short of room), Artboard Options, Rearrange All Artboards and the Transform +/// link (the Transform panel in a popover), which all fit in the narrowest window, left of the bar's +/// panel menu button; then Move and Scale Artwork with Artboard (the boxes alone, with their /// names in tooltips, when the bar is short of room) and, while the bar has room, the reference /// point the Transform panel also uses with the artboard's X and Y, and its W and H with the /// Constrain Width and Height Proportions link. Each control runs the command its Properties panel @@ -77,7 +82,8 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) -> bool { // The field shows the name as it is (names are never translated). A new name is applied on Enter // or a click elsewhere, and an empty field or Escape keeps the old one, as in the Properties panel. ui.label(egui::RichText::new(tl!("Name:")).size(12.0).color(t.text)); - if let Some(name) = widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), 120.0, 1).filter(|n| !n.is_empty()) { + let width = if ui.available_width() < NAME_FIELD_ROOM { 100.0 } else { 120.0 }; + if let Some(name) = widgets::text_field_named(ui, ("cb-ab", "name"), Some(tl!("Name:")), Some(&ab.name), width, 1).filter(|n| !n.is_empty()) { app.run("artboard.setProps", json!({"index": i, "name": name})).ok(); } if widgets::icon_button(ui, "dc-artboard-options", tl!("Artboard Options"), false, 24.0).clicked() { diff --git a/crates/ui-egui/src/panels/artboards/tests.rs b/crates/ui-egui/src/panels/artboards/tests.rs index c01d82eb1..256e7ceaf 100644 --- a/crates/ui-egui/src/panels/artboards/tests.rs +++ b/crates/ui-egui/src/panels/artboards/tests.rs @@ -849,11 +849,15 @@ fn the_control_bar_shows_the_artboard_tools_controls() { assert!(!has(&texts, "Snapping"), "{texts:?}"); let link = text_rect(&texts, "Transform"); assert!(link.right() < 790.0 && text_rect(&texts, "Artboard 1").right() < link.left(), "{texts:?}"); - // Move and Scale Artwork with Artboard show as their boxes alone, inside the window, and the - // second one turns Scale Artwork with Artboard on. + // Move and Scale Artwork with Artboard show as their boxes alone, inside the window and left of + // the panel menu button at the bar's right end, and the second one turns Scale Artwork with + // Artboard on. assert!(!has(&texts, "Move Artwork with Artboard"), "{texts:?}"); - let boxes = bar_buttons(&ctx, link.right(), 790.0); + let mut boxes = bar_buttons(&ctx, link.right(), 790.0); + let menu = boxes.pop().filter(|r| (r.width() - 16.0).abs() < 0.5 && r.right() > 785.0); + let menu = menu.unwrap_or_else(|| panic!("the panel menu button ends the bar: {boxes:?}")); assert_eq!(boxes.len(), 2, "{boxes:?}"); + assert!(boxes[1].right() <= menu.left(), "{boxes:?} and the panel menu button at {menu:?}"); let scale = |app: &VectorcraftApp| app.session.tool_options()["scaleArt"].as_bool(); assert_eq!(scale(&app), Some(false)); let press = diff --git a/crates/ui-egui/src/panels/color/tests.rs b/crates/ui-egui/src/panels/color/tests.rs index 7670d8cdf..9f9b3c1dd 100644 --- a/crates/ui-egui/src/panels/color/tests.rs +++ b/crates/ui-egui/src/panels/color/tests.rs @@ -64,6 +64,58 @@ fn a_recent_colour_recolours_the_selected_gradient_stop() { assert_eq!(hex, ["#ffffff", "#ff0000"]); } +/// In a narrow panel the Recent Colors chips wrap onto further rows, so all 30 can be clicked. +#[test] +fn all_thirty_recent_colors_wrap_into_a_narrow_panel() { + let mut app = app(); + let hex = |i: u8| Color::rgb8(i * 8, 0, 255 - i * 8).to_hex(); + for i in 0..30u8 { + app.run("paint.setFill", json!({"color": hex(i)})).unwrap(); + } + assert_eq!(app.session.recent_colors.len(), 30); + let ctx = egui::Context::default(); + let screen = Rect::from_min_size(egui::Pos2::ZERO, vec2(160.0, 2000.0)); + let chips = |app: &mut VectorcraftApp, events: Vec| { + let mut rects = vec![]; + let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }; + let mut full = ctx.run_ui(input, |ui| { + show(app, ui); + rects = (0..30usize).map(|i| ctx.read_response(ui.scope_id().with(("recent", i))).map(|r| r.rect)).collect(); + }); + full.textures_delta.clear(); + rects + }; + let rects: Vec = chips(&mut app, vec![]).into_iter().map(|r| r.expect("every chip is drawn")).collect(); + for (i, r) in rects.iter().enumerate() { + assert!(r.right() <= screen.right(), "chip {i} fits the panel's width: {r:?}"); + for (j, o) in rects.iter().enumerate().skip(i + 1) { + assert!(!r.intersects(*o), "chips {i} and {j} do not overlap"); + } + } + assert!(rects[29].top() > rects[0].top(), "the chips wrap onto another row"); + // The last chip, the oldest color, applies like the others. + let pos = rects[29].center(); + let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + chips(&mut app, vec![egui::Event::PointerMoved(pos), button(true)]); + chips(&mut app, vec![button(false)]); + let Paint::Solid { color, .. } = crate::panels::current_paints(&app).0 else { panic!("a solid fill") }; + assert_eq!(color.to_hex(), hex(0)); +} + +/// With a few recent colors the chips take one row, as tall as before. +#[test] +fn a_short_recent_colors_list_takes_one_row() { + let mut app = app(); + for h in ["#ff0000", "#00ff00", "#0000ff"] { + app.run("paint.setFill", json!({"color": h})).unwrap(); + } + let ctx = egui::Context::default(); + let first = frame_with(&mut app, &ctx, vec![], egui::Modifiers::NONE, ("recent", 0usize)).0.expect("the first chip is drawn"); + let last = frame_with(&mut app, &ctx, vec![], egui::Modifiers::NONE, ("recent", 2usize)).0.expect("the third chip is drawn"); + assert_eq!(first.top(), last.top()); + assert_eq!(last.left() - first.left(), 38.0); +} + /// In a CMYK document the sliders keep the model picked in the panel for a gradient stop and a /// freeform point, as they do for a solid fill. #[test] diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs index 7c7acbae7..cc0c60986 100644 --- a/crates/ui-egui/src/panels/history.rs +++ b/crates/ui-egui/src/panels/history.rs @@ -4,7 +4,7 @@ use egui::{Sense, Ui, vec2}; use serde_json::json; use crate::theme::Tokens; -use crate::widgets::menu_item; +use crate::widgets::{self, menu_item}; use crate::{VectorcraftApp, icons}; /// Undo (negative) / redo (positive) steps to reach row `target` when `current` is the current @@ -87,6 +87,15 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } } } + widgets::divider(ui); + // A click on a row moves to its state at once, so the selected state is the current one. + const NEW_DOCUMENT: &str = "edit.newDocumentFromState"; + let new_document = crate::menus::enabled(app, NEW_DOCUMENT); + ui.horizontal(|ui| { + if widgets::icon_button_enabled(ui, "file-plus", tl!("New Document from State"), false, new_document, 24.0).clicked() { + crate::menus::invoke(app, NEW_DOCUMENT, json!({})); + } + }); } pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -303,6 +312,37 @@ mod tests { assert_eq!(app.session.active().unwrap().revision, revision, "a click on the disabled item changes nothing"); } + /// The bottom of the panel's New Document from State button makes a new document, the active + /// one, from the state a click on a row moved to. The source document stays open at that state. + #[test] + fn the_new_document_button_makes_a_document_from_the_selected_state() { + let mut app = app_with_two_rectangles(); + app.session.execute("edit.undo", &json!({})).unwrap(); + let source = app.session.active().unwrap().doc.clone(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + egui_extras::install_image_loaders(&ctx); + let screen = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(240.0, 400.0))); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: screen, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default() + }; + frame(&mut app, vec![]); + let nodes = frame(&mut app, vec![]); + let button = nodes.iter().map(|(_, n)| n).find(|n| n.label() == Some("New Document from State")).expect("the button"); + let at = button.bounds().expect("its bounds"); + let at = egui::pos2((at.x0 + at.x1) as f32 / 2.0, (at.y0 + at.y1) as f32 / 2.0); + let press = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![egui::Event::PointerMoved(at), press(true), press(false)]); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.session.active_index(), Some(1), "the new document is the active one"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.layers[0].children().unwrap().len(), 1, "the art of the selected state"); + assert!(st.is_dirty(), "the new document is unsaved"); + assert!(std::sync::Arc::ptr_eq(&app.session.documents()[0].doc, &source), "the source stays at that state"); + } + /// Set History Limit… opens Preferences at Performance, the page with History States. #[test] fn set_history_limit_opens_the_history_states_preference() { diff --git a/crates/ui-egui/src/panels/image_trace.rs b/crates/ui-egui/src/panels/image_trace.rs index 73a1b8986..ce5a5955b 100644 --- a/crates/ui-egui/src/panels/image_trace.rs +++ b/crates/ui-egui/src/panels/image_trace.rs @@ -270,6 +270,21 @@ fn preset_label(name: &str) -> &str { crate::i18n::tr_ctx(crate::i18n::current(), "imageTrace", name) } +/// The buttons above the Image Trace panel's Preset dropdown, in panel order: each one's name and the +/// built-in preset a click chooses. The help page lists these options before Preset +/// (); +/// which preset each one chooses is VectorCraft's own. +fn preset_buttons() -> [(&'static str, &'static str); 6] { + [ + (preset_label("Auto Color"), "Auto Color"), + (tl!("High Color"), "High Fidelity Photo"), + (tl!("Low Color"), "Low Fidelity Photo"), + (tl!("Grayscale"), "Shades of Gray"), + (tl!("Black and White"), "Black and White Logo"), + (preset_label("Outline"), "Outline"), + ] +} + fn preset_list(app: &mut VectorcraftApp, ui: &mut Ui, current: &str) -> Option { let mut chosen = None; let list = presets(app, ui.ctx()); @@ -559,6 +574,23 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let restored = sync_with_object(&mut st, stored, object, &all, &changes); let mut retrace = false; + // A preset button chooses its preset as the Preset dropdown does; the button of the panel's + // preset shows as selected. + ui.horizontal_wrapped(|ui| { + for (label, name) in preset_buttons() { + let selected = st.preset == name; + if cfg!(test) { + ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique(("trace-preset-button", name)), selected)); + } + if ui.add(egui::Button::selectable(selected, label)).on_hover_text(preset_label(name)).clicked() + && let Some(p) = all.iter().find(|p| p.0 == name) + { + st.preset = name.to_string(); + st.params = p.1.clone(); + retrace = true; + } + } + }); ui.horizontal(|ui| { widgets::dim_label(ui, tl!("Preset:")); let manage = 24.0; diff --git a/crates/ui-egui/src/panels/image_trace/tests.rs b/crates/ui-egui/src/panels/image_trace/tests.rs index 56de0cdb0..7e797cead 100644 --- a/crates/ui-egui/src/panels/image_trace/tests.rs +++ b/crates/ui-egui/src/panels/image_trace/tests.rs @@ -1217,6 +1217,111 @@ fn the_preset_list_is_read_once_for_each_change_of_the_saved_presets() { assert_eq!((reads(), shown_preset(&ctx)), (start + 4, "D".to_string())); } +/// Choose `preset` from the Preset dropdown, which shows `shown`. +fn choose_from_preset_menu(app: &mut VectorcraftApp, ctx: &egui::Context, shown: &str, preset: &str) { + let texts = frame(app, ctx, vec![]); + let combo = texts.iter().rfind(|(t, _)| t == shown).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{shown}` in {texts:?}")); + click_at(app, ctx, combo); + let texts = frame(app, ctx, vec![]); + let at = texts.iter().rfind(|(t, _)| t == preset).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no `{preset}` in {texts:?}")); + click_at(app, ctx, at); +} + +/// Which preset buttons showed as selected at the last frame. +fn selected_preset_buttons(ctx: &egui::Context) -> Vec<&'static str> { + preset_buttons() + .into_iter() + .filter(|(_, name)| ctx.data(|d| d.get_temp::(egui::Id::unique(("trace-preset-button", *name)))) == Some(true)) + .map(|(_, name)| name) + .collect() +} + +#[test] +fn each_preset_button_traces_the_selected_object_again_with_its_preset() { + let mut app = traced_app_with_ink(100); + trace_and_open(&mut app, "Default"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + let undo = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + for (label, preset) in [ + ("Auto Color", "Auto Color"), + ("High Color", "High Fidelity Photo"), + ("Low Color", "Low Fidelity Photo"), + ("Grayscale", "Shades of Gray"), + ("Black and White", "Black and White Logo"), + ("Outline", "Outline"), + ] { + let steps = undo(&app); + click(&mut app, &ctx, label); + frame(&mut app, &ctx, vec![]); + assert_eq!(selected_trace(&app).unwrap().0, preset, "{label}"); + assert_eq!(undo(&app), steps + 1, "{label} traces as one undo step"); + let all = presets(&mut app, &ctx); + assert_eq!(target(&app).2.unwrap()["params"], find_preset(&all, preset).unwrap().1, "{label} traces with the preset's settings"); + assert_eq!((shown_preset(&ctx), selected_preset_buttons(&ctx)), (preset.to_string(), vec![preset]), "{label}"); + } + // Undo returns to the object's previous preset, and the buttons follow it. + app.run("edit.undo", json!({})).unwrap(); + frame(&mut app, &ctx, vec![]); + frame(&mut app, &ctx, vec![]); + assert_eq!(selected_trace(&app).unwrap().0, "Black and White Logo"); + assert_eq!(selected_preset_buttons(&ctx), ["Black and White Logo"]); +} + +#[test] +fn the_selected_preset_button_follows_the_preset_menu() { + let mut app = traced_app_with_ink(100); + trace_and_open(&mut app, "Default"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + assert!(selected_preset_buttons(&ctx).is_empty(), "Default has no button"); + choose_from_preset_menu(&mut app, &ctx, "Default", "Shades of Gray"); + assert_eq!(selected_trace(&app).unwrap().0, "Shades of Gray"); + assert_eq!(selected_preset_buttons(&ctx), ["Shades of Gray"]); + choose_from_preset_menu(&mut app, &ctx, "Shades of Gray", "6 Colors"); + assert_eq!(selected_trace(&app).unwrap().0, "6 Colors"); + assert!(selected_preset_buttons(&ctx).is_empty(), "6 Colors has no button"); + // A changed setting makes the preset Custom, and no button shows as selected. + click(&mut app, &ctx, "Outline"); + frame(&mut app, &ctx, vec![]); + assert_eq!(selected_preset_buttons(&ctx), ["Outline"]); + click(&mut app, &ctx, "Preview"); + click(&mut app, &ctx, "Fills"); + frame(&mut app, &ctx, vec![]); + assert_eq!(shown_preset(&ctx), "Custom"); + assert!(selected_preset_buttons(&ctx).is_empty()); +} + +#[test] +fn a_preset_button_with_no_image_selected_changes_the_panel_settings_as_the_preset_menu_does() { + let blank = || { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + app + }; + let mut app = blank(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, vec![]); + let steps = app.session.active().unwrap().history.undo.len(); + click(&mut app, &ctx, "High Color"); + frame(&mut app, &ctx, vec![]); + let by_button: TraceUi = pstate(&ctx, "image-trace"); + assert_eq!(app.session.active().unwrap().history.undo.len(), steps, "nothing is traced"); + assert!(target(&app).2.is_none(), "no Image Trace object"); + assert_eq!(selected_preset_buttons(&ctx), ["High Fidelity Photo"]); + + let mut menu_app = blank(); + let menu_ctx = egui::Context::default(); + crate::theme::install_fonts(&menu_ctx); + choose_from_preset_menu(&mut menu_app, &menu_ctx, "Default", "High Fidelity Photo"); + let by_menu: TraceUi = pstate(&menu_ctx, "image-trace"); + assert_eq!(menu_app.session.active().unwrap().history.undo.len(), steps); + assert_eq!((by_button.preset, by_button.params), (by_menu.preset, by_menu.params)); +} + #[test] fn panel_and_menu_draw_headless() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index a7fe30185..38f984876 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -2,8 +2,8 @@ //! and embedded images, as rows with a thumbnail, name, page and status. Clicking a row selects //! its image, Ctrl/Cmd-click adds or removes it, and Shift-click selects the images of the rows //! from the last row clicked without Shift to it (with Ctrl/Cmd, adds them); the buttons Relink, -//! Go To, Update and Edit Original act on the selected rows; Link Info shows the details of the -//! first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: +//! Go To, Update and Edit Original act on the selected rows (Relink through its dialog, with the +//! Replace choice: `ui.relinkDialog`); Link Info shows the details of the first. The flyout adds Relink to Folder, Find in Folder (Find Linked Files: //! `ui.findLinksInFolder`), Embed / Unembed, Placement Options, Select Linked / Embedded / Missing / //! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size //! and Show in Folder. @@ -128,16 +128,19 @@ pub(crate) fn updated_status(n: usize) -> String { crate::i18n::msg_n(n as u64, "Updated {n} image", "Updated {n} images", &[]) } -/// Relink images `ids` to a picked file. -pub(crate) fn relink(app: &mut VectorcraftApp, ids: Vec) { - crate::picks::button(app, move |app| relink_picked(app, ids.clone())); +/// The status bar's line for `n` relinked images, naming the files `missed` that weren't found. +pub(crate) fn relinked_status_missed(n: usize, missed: &[&str]) -> String { + if missed.is_empty() { + relinked_status(n) + } else { + let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); + crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) + } } -fn relink_picked(app: &mut VectorcraftApp, ids: Vec) { - let pick = crate::FilePick { filters: crate::FilePick::filters(vectorcraft_engine::cmd::fileio::place_filters()), ..Default::default() }; - let Some(path) = crate::picks::open(app, &pick) else { return }; - let r = app.run("links.relink", json!({ "ids": ids, "path": path })); - report(app, r, |v| relinked_status(v["relinked"].as_array().map_or(0, Vec::len))); +/// The ids of the selected image objects and placed documents (Relink's dialog). +pub(crate) fn selected_ids(app: &VectorcraftApp) -> Vec { + selected_images(app) } /// Replace images `ids` with a picked file (Control bar, Properties): it keeps its own aspect @@ -163,13 +166,7 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { let r = app.run("links.relink", json!({ "ids": ids, "folder": folder })); report(app, r, |v| { let missed: Vec<&str> = v["notFound"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); - let n = v["relinked"].as_array().map_or(0, Vec::len); - if missed.is_empty() { - relinked_status(n) - } else { - let (one, other) = ("Relinked {n} image; not in that folder: {names}", "Relinked {n} images; not in that folder: {names}"); - crate::i18n::msg_n(n as u64, one, other, &[("names", &missed.join(", "))]) - } + relinked_status_missed(v["relinked"].as_array().map_or(0, Vec::len), &missed) }); } @@ -426,7 +423,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { go_to(app, id); } if widgets::icon_button_enabled(ui, "link", tl!("Relink…"), false, !sel.is_empty() && can_pick(app), 24.0).clicked() { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if widgets::icon_button_enabled(ui, "link-2-off", tl!("Break Link"), false, !linked.is_empty(), 24.0).clicked() { break_link(app, linked.clone()); @@ -548,7 +545,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let any = !sel.is_empty(); let missing_any = list["missing"].as_u64().unwrap_or(0) > 0; if menu_item(ui, tl!("Relink…"), any && can_pick(app), false) { - relink(app, ids(&sel, |_| true)); + crate::menus::invoke(app, "ui.relinkDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Relink to Folder…"), (any || missing_any) && app.services.pick_folder.is_some(), false) { relink_to_folder(app, ids(&sel, |_| true)); diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 0423bc0c9..a1df5a927 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -599,8 +599,9 @@ const BAR_ALIGNMENTS: [&str; 3] = ["Align left", "Align center", "Align right"]; fn the_control_bar_shows_the_paragraph_alignment_of_the_selected_type() { let ctx = control_bar_ctx(); let mut app = control_bar_app(&[json!({"x": 20, "y": 50, "text": "Point"})]); - // Pinned, so that the room the buttons need can't creep up past common window widths. - for width in [1440.0, 1920.0] { + // Pinned, so that the room the buttons need can't creep up past common window widths. The + // bar's right end keeps the panel menu button's room. + for width in [1536.0, 1920.0] { let nodes = control_bar_nodes(&ctx, &mut app, width, vec![]); let ends = BAR_ALIGNMENTS.map(|n| control_bar_end(&nodes, n)); assert!(ends.iter().all(|e| e.is_some_and(|x| x <= f64::from(width) - 10.0)), "pinned: the buttons show on a {width} pt window: {ends:?}"); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8f3af21b0..3dd7da7d6 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -435,7 +435,7 @@ fn objects_on_path_section(app: &mut VectorcraftApp, ui: &mut Ui, oop: &ObjectsO egui::Grid::new("props-objects-on-path").num_columns(2).spacing([6.0, 6.0]).show(ui, |ui| { dim_label(ui, tl!("Pivot:")); // 9 is no square: the groups' pivots differ. - if let Some(i) = widgets::reference_point(ui, oop.pivot.map_or(9, usize::from)) + if let Some(i) = widgets::reference_point_named(ui, oop.pivot.map_or(9, usize::from), tl!("Pivot:")) && oop.pivot.is_none_or(|p| usize::from(p) != i) { set(app, json!({ "pivot": i })); diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 1b2ff3559..f42c47470 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -160,6 +160,13 @@ pub(crate) fn changed_instances_selected(app: &VectorcraftApp) -> bool { instance_selected(app, |c| !c.extras().symbol_overrides.is_empty()) } +/// Does the selection hold a symbol instance whose scale, rotation or shear differs from its +/// symbol's, which Reset Transformation restores? +fn transformed_instances_selected(app: &VectorcraftApp) -> bool { + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return false }; + instance_selected(app, |c| vectorcraft_engine::cmd::reset_transformation_changes(&doc, c)) +} + /// The symbols of the selected objects when every one is a symbol instance; `None` otherwise. fn selected_instance_symbols(app: &VectorcraftApp) -> Option> { let st = app.session.active()?; @@ -178,7 +185,9 @@ fn selected_instance_symbols(app: &VectorcraftApp) -> Option> { /// The Control bar's Replace menu while symbol instances alone are selected: the document's /// symbols, showing the one the instances share (blank when they differ); picking another /// replaces them all, as "Select a new symbol from the Replace dropdown in the Control panel" -/// (). +/// (), +/// and the Reset button, which gives them their symbol's scale, rotation and shear again +/// (). pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(symbols) = selected_instance_symbols(app) else { return }; let Some(st) = app.session.active() else { return }; @@ -199,6 +208,14 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { { app.status(e); } + // Reset: Reset Transformation, enabled while an instance's scale, rotation or shear differs + // from its symbol's. + let reset = transformed_instances_selected(app); + if ui.add_enabled_ui(reset, |ui| widgets::flat_button_fit(ui, tl!("Reset"), 48.0).on_hover_text(tl!("Reset Transformation"))).inner.clicked() + && let Err(e) = app.run("symbol.instance.resetTransform", json!({})) + { + app.status(e); + } ui.add_space(4.0); } @@ -328,7 +345,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Delete Symbol"), sel.is_some() || !marked.is_empty(), false) { delete(app, &marked, sel.as_deref(), false); } - let items: [(&str, bool, &str, Value); 7] = [ + let items: [(&str, bool, &str, Value); 8] = [ (tl!("Symbol Options…"), sel.is_some(), "ui.symbolOptions", json!({"edit": true, "name": sel})), // The selected instance, else the symbol selected in the panel. (tl!("Edit Symbol"), inst || sel.is_some(), "symbol.edit", if inst { json!({}) } else { name.clone() }), @@ -336,6 +353,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { (tl!("Replace Symbol"), inst && sel.is_some(), "symbol.replace", name.clone()), (tl!("Break Link to Symbol"), inst, "symbol.breakLink", json!({})), (tl!("Reset Symbol Instance"), changed_instances_selected(app), "symbol.instance.reset", json!({})), + (tl!("Reset Transformation"), transformed_instances_selected(app), "symbol.instance.resetTransform", json!({})), (tl!("Select All Instances"), sel.is_some(), "symbol.selectInstances", name), ]; for (label, enabled, cmd, params) in items { @@ -1300,4 +1318,64 @@ mod tests { let doc = &app.session.active().unwrap().doc; assert!(matches!(&doc.node(leaf).unwrap().kind, NodeKind::SymbolInstance { symbol, .. } if symbol == "Star"), "the instance is replaced"); } + + /// The panel menu's Reset Transformation and the Control bar's Reset are enabled only while a + /// selected instance's scale, rotation or shear differs from its symbol's, and give the + /// instance its symbol's again. + #[test] + fn reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it() { + let mut app = leaf_and_dot(); + let leaf = instance_of(&app, "Leaf").expect("a Leaf instance"); + app.run("select.set", json!({"ids": [leaf.0]})).unwrap(); + let xf = |app: &VectorcraftApp| match app.session.active().unwrap().doc.node(leaf).map(|n| &n.kind) { + Some(NodeKind::SymbolInstance { xf, .. }) => *xf, + _ => panic!("the Leaf instance"), + }; + let master = xf(&app); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let item_disabled = |app: &mut VectorcraftApp| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(236.0, 400.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| menu(app, ui)); + out.textures_delta.clear(); + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + nodes + .iter() + .find(|(_, n)| n.label().is_some_and(|l| l.trim() == "Reset Transformation")) + .map(|(_, n)| n.is_disabled()) + .expect("the menu item") + }; + // At its symbol's scale the menu item is disabled and a click changes nothing. + assert!(item_disabled(&mut app), "the item is disabled at the symbol's scale"); + let before = app.session.active().unwrap().doc.clone(); + click_menu(&ctx, &mut app, "Reset Transformation"); + assert!(Arc::ptr_eq(&before, &app.session.active().unwrap().doc), "the item is disabled"); + app.run("object.rotate", json!({"angle": 30})).unwrap(); + assert!(!item_disabled(&mut app), "a turned instance enables it"); + click_menu(&ctx, &mut app, "Reset Transformation"); + let close = + |a: vectorcraft_geom::Affine, b: vectorcraft_geom::Affine| a.as_coeffs().iter().zip(b.as_coeffs()).all(|(x, y)| (x - y).abs() < 1e-9); + assert!(close(xf(&app), master), "the menu item resets the instance: {:?} vs {master:?}", xf(&app)); + // The Control bar's Reset. + let bar = |app: &mut VectorcraftApp, events: Vec| { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 400.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.horizontal(|ui| control_bar(app, ui)); + }); + out.textures_delta.clear(); + out + }; + let reset_disabled = |out: &egui::FullOutput| { + let nodes = &out.platform_output.accesskit_update.as_ref().expect("an accessibility tree").nodes; + nodes.iter().find(|(_, n)| n.label().is_some_and(|l| l.trim() == "Reset")).map(|(_, n)| n.is_disabled()).expect("a Reset button") + }; + assert!(reset_disabled(&bar(&mut app, vec![])), "Reset is disabled at the symbol's scale"); + app.run("object.scale", json!({"sx": 150, "sy": 50})).unwrap(); + let out = bar(&mut app, vec![]); + assert!(!reset_disabled(&out)); + let at = clickable(&out).into_iter().find(|(l, _)| l == "Reset").expect("a Reset button").1; + bar(&mut app, vec![egui::Event::PointerMoved(at), button(at, true), button(at, false)]); + assert!(close(xf(&app), master), "the Control bar's Reset resets the instance: {:?} vs {master:?}", xf(&app)); + } } diff --git a/crates/ui-egui/src/panels/tests_constrain.rs b/crates/ui-egui/src/panels/tests_constrain.rs index b5e8ed7f7..899d335fa 100644 --- a/crates/ui-egui/src/panels/tests_constrain.rs +++ b/crates/ui-egui/src/panels/tests_constrain.rs @@ -297,10 +297,10 @@ fn the_link_is_a_preference_agents_can_read_and_set() { assert_eq!(size(&app), (200.0, 100.0)); } -/// One Tab moves from a field to the next field: a field's label (its scrub zone) is not a tab -/// stop of its own, and the W/H link between W and H, which is still focusable, comes after the -/// fields. In the Transform panel and the Properties panel the fields run X, W, Y, H (a grid, two -/// to a row); in the Control bar X, Y, W, H. +/// One Tab moves from a field to the next field: a field's label (its scrub zone), spinner and +/// preset chevron are not tab stops of their own, and the W/H link between W and H, which is +/// still focusable, comes after the fields. In the Transform panel and the Properties panel the +/// fields run X, W, Y, H (a grid, two to a row); in the Control bar X, Y, W, H. #[test] fn one_tab_moves_from_a_field_to_the_next() { // From X (the selection's centre, 60 pt) the next field is W in the panels and Y in the bar. @@ -328,6 +328,12 @@ fn one_tab_moves_from_a_field_to_the_next() { tab_then_type(&mut app, crate::chrome::control_bar, "400 pt", "200"); let r = app.session.doc().unwrap().doc.artboards[0].rect; assert_eq!((r.width().round(), r.height().round()), (400.0, 200.0), "Artboard Control bar: Tab went from W to H"); + // The Transform panel's Rotate and Shear fields each have a spinner and presets: Tab goes from + // Rotate straight to Shear, past Rotate's preset chevron and Shear's spinner. + let mut app = rect_app(); + tab_then_type(&mut app, transform::show, "0°", "30"); + let shear = app.session.active().map(|st| st.doc.bounds_of(&st.selection.objects, false).map(|b| (b.width().round(), b.height().round()))); + assert_eq!(shear, Some(Some((129.0, 50.0))), "Transform panel: Tab went from Rotate to Shear"); // Shift+Tab goes back one field, one press too: from W to X in the panels. for (name, draw) in [("Transform panel", transform::show as Draw), ("Properties", properties::transform_section as Draw)] { let mut app = rect_app(); diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs new file mode 100644 index 000000000..3c94bf88a --- /dev/null +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -0,0 +1,411 @@ +//! The 3D and Materials panel drawn headless: its Window menu entry, the object types applying or +//! changing a 3D effect, the dialogs' controls for the applied effect, and one undo step for a +//! drag or a typed value. +#![allow(clippy::unwrap_used, clippy::panic)] + +use serde_json::{Map, Value, json}; +use vectorcraft_engine::Session; + +use super::{ID, Target, changed_settings, send, set_type, show, target}; +use crate::tests_labels::painted_text; +use crate::{Services, VectorcraftApp}; + +/// An app with a selected blue square. +fn app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + app.run("file.new", json!({"width": 400, "height": 400})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap(); + app.run("paint.setFill", json!({"color": "#338ad6"})).unwrap(); + app +} + +fn undo_len(app: &VectorcraftApp) -> usize { + app.session.doc().unwrap().history.undo.len() +} + +fn effect(app: &VectorcraftApp) -> (String, Value) { + let t = target(app).unwrap(); + (t.effect, t.params) +} + +#[test] +fn the_window_menu_opens_the_panel() { + let mut app = app(); + assert!(crate::state::ICON_PANELS.iter().any(|(id, label, _)| *id == ID && *label == "3D and Materials")); + app.run("window.panel", json!({"panel": ID})).unwrap(); + assert!(crate::panel_docking::showing(&app.ui, ID)); + app.run("window.panel", json!({"panel": "3D and Materials"})).unwrap(); +} + +#[test] +fn without_a_3d_effect_the_panel_offers_the_object_types() { + let mut app = app(); + let text = painted_text(&mut app, show); + for label in ["Plane", "Extrude", "Revolve", "Inflate", "Select objects, then pick an object type"] { + assert!(text.contains(label), "{label} in {text}"); + } + app.run("select.none", json!({})).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains("Select objects, then pick an object type"), "{text}"); +} + +#[test] +fn an_object_type_applies_its_effect_then_changes_it_in_place() { + let mut app = app(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(effect(&app).0, "threeD.extrude"); + assert_eq!(undo_len(&app), history + 1); + app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"rotationX": 15.0, "depth": 70.0}})).unwrap(); + for (kind, id) in [("inflate", "threeD.inflate"), ("revolve", "threeD.revolve"), ("plane", "threeD.rotate"), ("extrude", "threeD.extrude")] { + let history = undo_len(&app); + set_type(&mut app, kind).unwrap(); + let (now, params) = effect(&app); + assert_eq!(now, id); + assert_eq!(params["rotationX"], 15.0, "{kind}"); + assert_eq!(undo_len(&app), history + 1, "{kind}"); + // The object keeps one effect: the type changed in place. + assert_eq!(app.session.active().unwrap().doc.node(app.session.active().unwrap().selection.objects[0]).unwrap().appearance.effects.len(), 1); + } + // The type it already has runs nothing. + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(undo_len(&app), history); + assert!(set_type(&mut app, "cube").unwrap_err().contains("must be one of plane, extrude, revolve, inflate")); + assert_eq!(undo_len(&app), history); +} + +/// The ids of the effects of objects `ids`, in order. +fn effect_ids(app: &VectorcraftApp, ids: &[u64]) -> Vec> { + let st = app.session.active().unwrap(); + ids.iter().map(|id| st.doc.node(vectorcraft_doc::NodeId(*id)).unwrap().appearance.effects.iter().map(|e| e.id.clone()).collect()).collect() +} + +#[test] +fn an_object_type_reaches_every_selected_object_in_one_undo_step() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": null})).unwrap(); + let c = app.run("shape.rectangle", json!({"x": 100, "y": 250, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + // The first object already has the type; the second has another; the third has none. + for order in [[a, b, c], [c, b, a]] { + app.run("select.set", json!({"ids": order})).unwrap(); + let before = app.session.doc().unwrap().doc.clone(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(effect_ids(&app, &[a, b, c]), vec![vec!["threeD.extrude".to_string()]; 3], "{order:?}"); + assert_eq!(undo_len(&app), history + 1, "{order:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc().unwrap().doc, before, "{order:?}"); + } + // Every selected object already has the type: nothing is recorded. + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + set_type(&mut app, "extrude").unwrap(); + let history = undo_len(&app); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn a_drag_ends_when_the_selection_goes_away() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + assert!(app.session.in_interaction()); + app.session.active_mut().unwrap().selection.objects.clear(); + ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)).textures_delta.clear(); + assert!(!app.session.in_interaction()); + assert_eq!(undo_len(&app), history + 1); +} + +#[test] +fn with_nothing_selected_an_object_type_changes_nothing() { + let mut app = app(); + app.run("select.none", json!({})).unwrap(); + let history = undo_len(&app); + assert!(set_type(&mut app, "plane").is_err()); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn the_panel_shows_the_dialog_controls_of_each_type() { + let mut app = app(); + for (kind, label) in [("extrude", "Depth"), ("inflate", "Volume"), ("revolve", "Revolve angle"), ("plane", "Rotation X")] { + set_type(&mut app, kind).unwrap(); + let text = painted_text(&mut app, show); + assert!(text.contains(label), "{kind}: {label} in {text}"); + assert!(text.contains("Lighting"), "{kind}: {text}"); + // Drawing the panel is no edit. + let history = undo_len(&app); + painted_text(&mut app, show); + assert_eq!(undo_len(&app), history, "{kind}"); + } + // The dialog's artboard-axis toggle has no artboard gizmo in the panel. + set_type(&mut app, "revolve").unwrap(); + assert!(!painted_text(&mut app, show).contains("Show axis")); +} + +#[test] +fn a_drag_previews_and_records_one_undo_step_when_released() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + for depth in [60.0, 80.0, 120.0] { + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(depth))]), true); + assert_eq!(effect(&app).1["depth"], depth); + } + // Another setting in the same drag keeps the earlier one. + send(&mut app, &ctx, Map::from_iter([("twist".to_string(), json!(45.0))]), true); + assert_eq!(effect(&app).1["depth"], 120.0); + assert_eq!(undo_len(&app), history); + // Released without a change in the last frame: the drag commits. + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); + let (_, params) = effect(&app); + assert_eq!((params["depth"].clone(), params["twist"].clone()), (json!(120.0), json!(45.0))); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(effect(&app).1["depth"], 50.0); + // A typed value is one undo step at once. + send(&mut app, &ctx, Map::from_iter([("taper".to_string(), json!(60.0))]), false); + assert_eq!(effect(&app).1["taper"], 60.0); + assert_eq!(undo_len(&app), history + 1); + // Nothing changed and no drag: nothing runs. + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); +} + +#[test] +fn a_change_reaches_each_selected_3d_effect_and_no_other_effect() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + // A second object with Inflate after a drop shadow, a third with only a drop shadow. + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": null})).unwrap(); + let c = app.run("shape.rectangle", json!({"x": 100, "y": 250, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "stylize.dropShadow", "item": null})).unwrap(); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + let shadow = |app: &VectorcraftApp, id: u64| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params.clone() + }; + let (b_shadow, c_shadow) = (shadow(&app, b), shadow(&app, c)); + let history = undo_len(&app); + let ctx = egui::Context::default(); + // `bevel` is an Extrude & Bevel setting only; `depth` both effects take. + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(75.0)), ("bevel".to_string(), json!("classic"))]), true); + send(&mut app, &ctx, Map::new(), false); + assert_eq!(undo_len(&app), history + 1); + let st = app.session.active().unwrap(); + let fx = |id: u64| st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects.clone(); + assert_eq!(fx(a)[0].params["depth"], 75.0); + assert_eq!(fx(a)[0].params["bevel"], "classic"); + assert_eq!(fx(b)[1].params["depth"], 75.0); + assert!(fx(b)[1].params.get("bevel").is_none()); + assert_eq!(fx(b)[0].params, b_shadow); + assert_eq!(fx(c)[0].params, c_shadow); +} + +#[test] +fn the_panel_finds_a_3d_effect_on_a_fill() { + let mut app = app(); + app.run("effect.apply", json!({"effect": "threeD.inflate", "item": 0})).unwrap(); + let t = target(&app).unwrap(); + assert_eq!((t.item, t.index, t.effect.as_str()), (Some(0), 0, "threeD.inflate")); + set_type(&mut app, "extrude").unwrap(); + assert_eq!(target(&app).map(|t: Target| (t.item, t.effect)), Some((Some(0), "threeD.extrude".to_string()))); +} + +#[test] +fn settings_the_selected_effects_disagree_on_are_unset_so_a_reset_reaches_them_all() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"depth": 80.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let mut fields = target(&app).map(|t| vectorcraft_effects::merged_params(&t.effect, &t.params)).unwrap().as_object().cloned().unwrap(); + super::mark_mixed(&app, &mut fields); + assert_eq!(fields["depth"], Value::Null); + assert_eq!(fields["twist"], 0.0); + // Resetting Depth to its default (the first object's value) is a change for both objects. + let before = Value::Object(fields.clone()); + fields.insert("depth".into(), json!(50.0)); + let changed = changed_settings(&before, &Value::Object(fields)); + assert_eq!(Value::Object(changed.clone()), json!({"depth": 50.0})); + send(&mut app, &egui::Context::default(), changed, false); + let st = app.session.active().unwrap(); + for id in [a, b] { + assert_eq!(st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["depth"], 50.0); + } +} + +#[test] +fn a_light_list_edit_reaches_only_the_objects_with_the_same_lights() { + let mut app = app(); + let two = json!([{"color": "#ff8040"}, {"color": "#4080ff", "rotation": -35.0}]); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": two}})).unwrap(); + let a = app.session.active().unwrap().selection.objects[0].0; + let add = |app: &mut VectorcraftApp, x: f64, lights: Value| { + let id = app.run("shape.rectangle", json!({"x": x, "y": 250, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": lights}})).unwrap(); + id + }; + let b = add(&mut app, 50.0, json!([{"color": "#00ff00"}])); + let c = add(&mut app, 200.0, two.clone()); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + let lights = |app: &VectorcraftApp, id: u64| { + app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["lights"].clone() + }; + let b_before = lights(&app, b); + let three = json!([{"color": "#ff8040"}, {"color": "#4080ff", "rotation": -35.0}, {"color": "#ffffff", "rotation": 0.0, "height": 90.0}]); + let history = undo_len(&app); + send(&mut app, &egui::Context::default(), Map::from_iter([("lights".to_string(), three.clone())]), false); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(lights(&app, a).as_array().map(Vec::len), Some(3)); + assert_eq!(lights(&app, c), lights(&app, a)); + assert_eq!(lights(&app, b), b_before); +} + +#[test] +fn a_drag_keeps_its_objects_and_a_new_selection_starts_a_new_step() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0]; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let b = vectorcraft_doc::NodeId(b); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null})).unwrap(); + app.run("select.set", json!({"ids": [a.0]})).unwrap(); + let history = undo_len(&app); + let ctx = egui::Context::default(); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + // The selection changes during the drag: the drag on `a` is committed, and `b` starts its own. + app.session.active_mut().unwrap().selection.objects = vec![b]; + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(70.0))]), true); + send(&mut app, &ctx, Map::new(), false); + let depth = |app: &VectorcraftApp, id| app.session.active().unwrap().doc.node(id).unwrap().appearance.effects[0].params["depth"].clone(); + assert_eq!(depth(&app, a), 90.0); + assert_eq!(depth(&app, b), 70.0); + assert_eq!(undo_len(&app), history + 2); +} + +#[test] +fn a_light_drag_keeps_the_objects_whose_lights_matched_when_it_started() { + let mut app = app(); + let one = |color: &str| json!([{"color": color}]); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": one("#ff0000")}})).unwrap(); + let a = app.session.active().unwrap().selection.objects[0].0; + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"lights": one("#00ff00")}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + // The first object's list passes through the second's value during the drag. + send(&mut app, &ctx, Map::from_iter([("lights".to_string(), one("#00ff00"))]), true); + send(&mut app, &ctx, Map::from_iter([("lights".to_string(), one("#0000ff"))]), true); + send(&mut app, &ctx, Map::new(), false); + let lights = + |id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params["lights"].clone(); + assert_eq!(lights(a), one("#0000ff")); + assert_eq!(lights(b), one("#00ff00")); +} + +#[test] +fn a_drag_ends_when_the_panel_is_no_longer_drawn() { + let mut app = app(); + set_type(&mut app, "extrude").unwrap(); + let ctx = egui::Context::default(); + let history = undo_len(&app); + ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui)).textures_delta.clear(); + send(&mut app, &ctx, Map::from_iter([("depth".to_string(), json!(90.0))]), true); + // A pass that draws the panel keeps the drag; the first pass that does not draw it ends it. + ctx.run_ui(egui::RawInput::default(), |ui| { + // Drawn with the pointer still down: only the mark `show` leaves. + crate::panels::set_pstate(ui.ctx(), super::DRAWN, Some(super::root_pass(ui.ctx()))); + super::end_stale_drag(&mut app, ui.ctx()); + }) + .textures_delta + .clear(); + assert!(app.session.in_interaction()); + ctx.run_ui(egui::RawInput::default(), |ui| super::end_stale_drag(&mut app, ui.ctx())).textures_delta.clear(); + assert!(!app.session.in_interaction()); + assert_eq!(undo_len(&app), history + 1); + assert_eq!(effect(&app).1["depth"], 90.0); +} + +/// The panel's controls, drawn once with the accessibility tree on. +fn controls(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() +} + +fn named<'a>(nodes: &'a [egui::accesskit::Node], label: &str) -> &'a egui::accesskit::Node { + nodes.iter().find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}")) +} + +/// A checkbox setting the selected effects hold with different values shows a dash, leaves the +/// controls it guards available, and another change leaves it as each effect has it. +#[test] +fn a_mixed_checkbox_shows_a_dash_and_another_change_keeps_each_effects_value() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null, "params": {"shade": false}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Lighting").center(), show); + let nodes = controls(&mut app, &ctx); + assert_eq!(named(&nodes, "Shading").toggled(), Some(egui::accesskit::Toggled::Mixed), "a mixed Shading shows a dash"); + let behind = named(&nodes, "Light behind the object"); + assert!(!behind.is_disabled(), "a mixed Shading leaves the light settings available"); + assert!(!named(&nodes, "Add Light").is_disabled()); + let at = behind.bounds().map(|r| egui::pos2(((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)).unwrap(); + let history = undo_len(&app); + click(&ctx, &mut app, at, show); + assert_eq!(undo_len(&app), history + 1); + let params = |id: u64| app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().appearance.effects[0].params.clone(); + let merged = |id: u64| vectorcraft_effects::merged_params("threeD.extrude", ¶ms(id)); + assert_eq!((merged(a)["shade"].clone(), merged(b)["shade"].clone()), (json!(true), json!(false))); + assert_eq!((merged(a)["lightBehind"].clone(), merged(b)["lightBehind"].clone()), (json!(true), json!(true))); + // A single light whose settings differ: Add Light is off. + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("effect.setParams", json!({"index": 0, "item": null, "params": {"lightIntensity": 40.0}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + assert!(named(&controls(&mut app, &ctx), "Add Light").is_disabled()); +} + +/// Revolve's Cap checkbox shows a dash when the selected effects disagree on it. +#[test] +fn a_mixed_revolve_cap_shows_a_dash() { + let mut app = app(); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "revolve").unwrap(); + let b = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.revolve", "item": null, "params": {"cap": false}})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let history = undo_len(&app); + assert_eq!(named(&controls(&mut app, &ctx), "Cap").toggled(), Some(egui::accesskit::Toggled::Mixed)); + assert_eq!(undo_len(&app), history); +} + +#[test] +fn changed_settings_lists_only_new_values() { + let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); + let after = json!({"depth": 60.0, "cap": true, "bevel": "classic", "twist": 0.0}); + let changed = changed_settings(&before, &after); + assert_eq!(Value::Object(changed), json!({"depth": 60.0, "bevel": "classic", "twist": 0.0})); + assert!(changed_settings(&after, &after).is_empty()); +} diff --git a/crates/ui-egui/src/panels/text_styles.rs b/crates/ui-egui/src/panels/text_styles.rs index 8aaff6849..eb2908400 100644 --- a/crates/ui-egui/src/panels/text_styles.rs +++ b/crates/ui-egui/src/panels/text_styles.rs @@ -10,7 +10,7 @@ use std::cell::RefCell; use egui::{Color32, Sense, Ui, vec2}; use serde_json::{Map, Value, json}; use vectorcraft_doc::Document; -use vectorcraft_engine::cmd::textstyles; +use vectorcraft_engine::cmd::{fileio, textstyles}; use super::character::{end_typing, text_editing, text_style}; use super::memo::Memo; @@ -339,7 +339,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { /// The panel menu. Select All Unused highlights the styles no text uses (`charStyle.unused`, /// `paraStyle.unused`), Duplicate copies every highlighted style and highlights the copies, and -/// Delete deletes every highlighted style but the Normal style. +/// Delete deletes every highlighted style but the Normal style. Load Character Styles… (Load +/// Paragraph Styles…) and Load All Styles… add the styles of a document picked in an open dialog +/// ([`load`]); they are off without an open document or an open dialog. pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { let all = styles(app, kind); let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect(); @@ -394,6 +396,37 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) { app.run("ui.paramDialog", json!({ "command": format!("{p}.rename"), "label": label, "params": { "name": n, "to": n } })).ok(); } } + ui.separator(); + // As commands, so a file dialog shown off the UI thread loads the styles when it answers. + let can_load = app.session.active().is_some() && app.services.pick_open.is_some(); + let one = if kind == Kind::Char { + (tl!("Load Character Styles…"), "ui.loadCharStyles") + } else { + (tl!("Load Paragraph Styles…"), "ui.loadParaStyles") + }; + for (label, cmd) in [one, (tl!("Load All Styles…"), "ui.loadAllStyles")] { + if menu_item(ui, label, can_load, false) + && let Err(e) = app.run(cmd, json!({})) + { + app.status(e); + } + } +} + +/// Load Character Styles…, Load Paragraph Styles… and Load All Styles… (`ui.loadCharStyles`, +/// `ui.loadParaStyles`, `ui.loadAllStyles`): the engine command `load` (`charStyle.load`, +/// `paraStyle.load`, `textStyle.loadAll`) on the document at `path`, else on one picked in an open +/// dialog. A cancelled dialog loads nothing. +pub(crate) fn load(app: &mut VectorcraftApp, load: &str, path: Option) -> Result { + let path = match path { + Some(path) => path, + None => { + let pick = crate::FilePick { filters: crate::FilePick::filters(fileio::open_filters()), ..Default::default() }; + let Some(path) = crate::picks::open(app, &pick) else { return Ok(Value::Null) }; + path + } + }; + app.run(load, json!({ "path": path })) } #[cfg(test)] diff --git a/crates/ui-egui/src/panels/text_styles/tests.rs b/crates/ui-egui/src/panels/text_styles/tests.rs index 144e2d525..f3218bc35 100644 --- a/crates/ui-egui/src/panels/text_styles/tests.rs +++ b/crates/ui-egui/src/panels/text_styles/tests.rs @@ -766,6 +766,44 @@ fn dragging_a_highlighted_style_carries_every_highlighted_style() { } } +/// The panel menu's Load Character Styles… (Load Paragraph Styles…) adds that kind of style from +/// the document picked in the open dialog, and Load All Styles… adds both kinds, each in one undo +/// step. Without an open dialog the items are off. +#[test] +fn panel_menus_load_the_styles_of_a_picked_document() { + let mut src = vectorcraft_engine::Session::new(); + src.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + src.execute("charStyle.new", &json!({"name": "Lead", "attrs": {"size": 30.0}})).unwrap(); + src.execute("paraStyle.new", &json!({"name": "Centered", "attrs": {"justify": "Center"}})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-panel-load-styles-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Styles.vectorcraft"); + std::fs::write(&path, vectorcraft_format::save(&src.doc().unwrap().doc, false)).unwrap(); + let names = |app: &mut VectorcraftApp, kind| styles(app, kind).into_iter().skip(1).map(|s| s.0).collect::>(); + for kind in [Kind::Char, Kind::Para] { + let (mut app, _) = several_app(); + let ctx = egui::Context::default(); + let items = |app: &mut VectorcraftApp, ui: &mut egui::Ui| menu(app, ui, kind); + let one = format!(" Load {} Styles…", kind.what()); + let doc = app.session.doc().unwrap().doc.clone(); + click_text_held(&ctx, &mut app, &one, egui::Modifiers::NONE, items); + assert_eq!(app.session.doc().unwrap().doc, doc, "{} Styles: off without an open dialog", kind.what()); + let picked = path.to_string_lossy().to_string(); + app.services.pick_open = Some(Box::new(move |_: &crate::FilePick| Some(picked.clone()))); + let undo = app.session.doc().unwrap().history.undo.len(); + click_text_held(&ctx, &mut app, &one, egui::Modifiers::NONE, items); + let (added, other) = if kind == Kind::Char { ("Lead", Kind::Para) } else { ("Centered", Kind::Char) }; + assert_eq!(names(&mut app, kind), ["A", "B", "C", "D", added], "{} Styles", kind.what()); + assert_eq!(names(&mut app, other), ["A", "B", "C", "D"], "{} Styles: the other kind", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + click_text_held(&ctx, &mut app, " Load All Styles…", egui::Modifiers::NONE, items); + assert_eq!(names(&mut app, Kind::Char), ["A", "B", "C", "D", "Lead"], "{} Styles: Load All Styles", kind.what()); + assert_eq!(names(&mut app, Kind::Para), ["A", "B", "C", "D", "Centered"], "{} Styles: Load All Styles", kind.what()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2); + } + std::fs::remove_dir_all(&dir).ok(); +} + /// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing /// settlement here: the capture dispatch itself must close the live typing interaction. fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) { diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs new file mode 100644 index 000000000..25dfaa684 --- /dev/null +++ b/crates/ui-egui/src/panels/three_d.rs @@ -0,0 +1,272 @@ +//! Window › 3D and Materials: edits the selection's 3D effect in place. The object types (Plane, +//! Extrude, Revolve, Inflate) apply a 3D effect to a selection without one (`effect.apply`) or +//! change the applied one (`effect.setParams` with `threeDType`). Below them are the 3D effect +//! dialogs' own Object, Rotation and Lighting controls; each change runs `effect.setParams` as one +//! undo step, and a drag previews and commits once, when the pointer is released. + +use egui::Ui; +use serde_json::{Map, Value, json}; +use vectorcraft_doc::{Effect, Node}; +use vectorcraft_effects::{THREE_D_TYPES, is_three_d}; + +use super::{live_run, pstate, set_pstate}; +use crate::VectorcraftApp; +use crate::state::Dialog; +use crate::widgets::{self, Live, dim_label}; + +/// The panel's id (`window.panel`). +pub const ID: &str = "threeD"; + +/// The name of the undo step a change makes, as the effect dialogs' commits name it. +const LABEL: &str = "Effect Options"; + +/// The selection's 3D effect: the appearance item whose effects hold it (`None`: the object's +/// own effects), its index there, and its id and parameters. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Target { + pub item: Option, + pub index: usize, + pub effect: String, + pub params: Value, +} + +/// The 3D effect of the first selected object: its own effects first, then its fills' and +/// strokes'. `None` without a selection or without a 3D effect. +pub(crate) fn target(app: &VectorcraftApp) -> Option { + let st = app.session.active()?; + let n = st.selection.objects.first().and_then(|id| st.doc.node(*id))?; + find(n) +} + +fn find(n: &Node) -> Option { + let in_list = |fx: &[Effect], item: Option| { + fx.iter().position(|e| is_three_d(&e.id)).map(|index| Target { item, index, effect: fx[index].id.clone(), params: fx[index].params.clone() }) + }; + in_list(&n.appearance.effects, None).or_else(|| n.appearance.items.iter().enumerate().find_map(|(k, item)| in_list(item.effects(), Some(k)))) +} + +/// The panel's object type of effect `id` (`plane`, `extrude`, `revolve` or `inflate`). +pub(crate) fn type_of(id: &str) -> Option<&'static str> { + THREE_D_TYPES.iter().find(|(_, effect)| *effect == id).map(|(kind, _)| *kind) +} + +/// Picks object type `kind` for each selected object: changes its 3D effect into that type, or +/// applies that type's effect to it when it has none, all as one undo step. An object whose 3D +/// effect already has that type is left as it is (`effect.setParams` records nothing for it). +pub(crate) fn set_type(app: &mut VectorcraftApp, kind: &str) -> Result<(), String> { + let Some(&(_, effect)) = THREE_D_TYPES.iter().find(|(k, _)| *k == kind) else { + let key = "threeDType"; + return Err(format!("`{key}` must be one of {}", THREE_D_TYPES.map(|(k, _)| k).join(", "))); + }; + let steps: Vec<(&str, Value)> = app.session.active().map_or_else(Vec::new, |st| { + st.selection + .objects + .iter() + .filter_map(|id| st.doc.node(*id).map(|n| (id.0, find(n)))) + .map(|(id, t)| match t { + Some(t) => ("effect.setParams", json!({"ids": [id], "index": t.index, "item": t.item, "threeDType": kind})), + None => ("effect.apply", json!({"ids": [id], "effect": effect, "item": null})), + }) + .collect() + }); + match steps.as_slice() { + // Without a selection, `effect.apply` says why nothing changed. + [] => app.run("effect.apply", json!({"effect": effect, "item": null})).map(|_| ()), + [(id, p)] => app.run(id, p.clone()).map(|_| ()), + _ => { + app.run("edit.undoGroup.begin", json!({"label": LABEL}))?; + for (id, p) in &steps { + if let Err(e) = app.run(id, p.clone()) { + app.run("edit.undoGroup.end", json!({"cancel": true})).ok(); + return Err(e); + } + } + app.run("edit.undoGroup.end", json!({})).map(|_| ()) + } + } +} + +/// Commits the drag in progress, when the panel has one. +fn finish(app: &mut VectorcraftApp, ctx: &egui::Context) { + if !pstate::>(ctx, PENDING).is_empty() { + app.session.commit_interaction().ok(); + app.sync_views(); + } + set_pstate(ctx, PENDING, Map::::new()); + set_pstate(ctx, PLAN, None::); +} + +/// What a change goes to, taken when it starts (before any preview): the selected objects' ids, +/// and each selected object with a 3D effect, the effect, and whether its list of lights matches +/// the first one's (a light list edited in the panel replaces only those). +#[derive(Clone, Debug)] +struct Plan { + selection: Vec, + targets: Vec<(u64, Target, bool)>, +} + +fn plan(app: &VectorcraftApp) -> Plan { + let Some(st) = app.session.active() else { return Plan { selection: vec![], targets: vec![] } }; + let selection: Vec = st.selection.objects.iter().map(|id| id.0).collect(); + let found: Vec<(u64, Target)> = st.selection.objects.iter().filter_map(|id| Some((id.0, find(st.doc.node(*id)?)?))).collect(); + let lights = |t: &Target| vectorcraft_effects::merged_params(&t.effect, &t.params).get("lights").cloned(); + let first = found.first().map(|(_, t)| lights(t)); + let targets = found + .into_iter() + .map(|(id, t)| { + let same = first.as_ref() == Some(&lights(&t)); + (id, t, same) + }) + .collect(); + Plan { selection, targets } +} + +/// Sends the settings `changed` to the 3D effect of each selected object (only the settings its +/// effect takes; objects without a 3D effect are left as they are): previews them while the +/// pointer is down (`dragging`) and commits them as one undo step when it is released. Settings +/// changed earlier in the same drag are sent again with them, to the objects the drag started +/// with, since each preview starts again from the document as it was before the drag. A drag +/// whose selection changes is committed first, and the change starts a new one. +pub(crate) fn send(app: &mut VectorcraftApp, ctx: &egui::Context, changed: Map, dragging: bool) { + if changed.is_empty() { + // A drag that ended without a change in its last frame is committed now. + if !dragging { + finish(app, ctx); + } + return; + } + let selection: Vec = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default(); + let mut held: Option = pstate(ctx, PLAN); + if held.as_ref().is_some_and(|p| p.selection != selection) { + finish(app, ctx); + held = None; + } + let plan = held.unwrap_or_else(|| plan(app)); + let mut pending: Map = pstate(ctx, PENDING); + pending.extend(changed); + let steps: Vec = plan + .targets + .iter() + .filter_map(|(id, t, same_lights)| { + let takes = vectorcraft_effects::default_params(&t.effect).unwrap_or(Value::Null); + let params: Map = pending + .iter() + .filter(|(k, _)| takes.get(k.as_str()).is_some() && (k.as_str() != "lights" || *same_lights)) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + (!params.is_empty()) + .then(|| json!({"command": "effect.setParams", "params": {"ids": [id], "index": t.index, "item": t.item, "params": params}})) + }) + .collect(); + let phase = if dragging { Live::Dragging } else { Live::Released }; + match steps.as_slice() { + [] => {} + [step] => live_run(app, LABEL, "effect.setParams", step["params"].clone(), phase), + _ => live_run(app, LABEL, "command.batch", json!({"label": LABEL, "commands": steps}), phase), + } + if dragging { + set_pstate(ctx, PENDING, pending); + set_pstate(ctx, PLAN, Some(plan)); + } else { + set_pstate(ctx, PENDING, Map::::new()); + set_pstate(ctx, PLAN, None::); + } +} + +/// Panel state key of the [`Plan`] of the drag in progress. +const PLAN: &str = "three-d-plan"; +/// Panel state key of the pass the panel was last drawn in. +const DRAWN: &str = "three-d-drawn"; + +/// Commits the panel's drag in progress when the panel was not drawn in this pass (it was closed +/// or hidden during the drag), so the drag is one undo step and no preview stays open into a later +/// pass. The app runs this at the end of every pass, after the panels. +pub(crate) fn end_stale_drag(app: &mut VectorcraftApp, ctx: &egui::Context) { + let drawn: Option = pstate(ctx, DRAWN); + if drawn.is_some_and(|pass| pass != root_pass(ctx)) { + finish(app, ctx); + set_pstate(ctx, DRAWN, None::); + } +} + +/// The main window's pass in progress. A floating window draws inside it and counts its own +/// passes, so the panel's mark uses this one wherever the panel draws. +fn root_pass(ctx: &egui::Context) -> u64 { + ctx.cumulative_pass_nr_for(egui::ViewportId::ROOT) +} + +/// Panel state key of the settings changed during the drag in progress. +const PENDING: &str = "three-d-pending"; +/// Panel state key of the controls' own state (the open tab, the selected light). +const UI_STATE: &str = "three-d-ui"; + +/// The 3D and Materials panel. +pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { + let ctx = ui.ctx().clone(); + set_pstate(&ctx, DRAWN, Some(root_pass(&ctx))); + let target = target(app); + let selected = app.session.active().is_some_and(|st| !st.selection.objects.is_empty()); + let current = target.as_ref().and_then(|t| type_of(&t.effect)); + let labels = [tl!("Plane"), tl!("Extrude"), tl!("Revolve"), tl!("Inflate")]; + let at = current.and_then(|kind| THREE_D_TYPES.iter().position(|(k, _)| *k == kind)).unwrap_or(usize::MAX); + let picked = ui.add_enabled_ui(selected, |ui| widgets::tab_bar(ui, &labels, at)).inner; + if let Some((kind, _)) = picked.and_then(|i| THREE_D_TYPES.get(i)) + && let Err(e) = set_type(app, kind) + { + app.ui.status = e; + } + ui.add_space(8.0); + let Some(t) = target else { + // The selection changed during a drag: the drag ends with what it previewed. + finish(app, &ctx); + dim_label(ui, tl!("Select objects, then pick an object type to give them a 3D effect.")); + return; + }; + // The applied effect's settings in the dialog's form, with the controls' own state. + let mut fields = vectorcraft_effects::merged_params(&t.effect, &t.params).as_object().cloned().unwrap_or_default(); + mark_mixed(app, &mut fields); + fields.extend(pstate::>(&ctx, UI_STATE)); + fields.insert("__effect".into(), json!(t.effect)); + fields.insert("__panel".into(), json!(true)); + let mut d = Dialog { kind: ID.into(), fields }; + let before = crate::dialogs::form::params(&d); + crate::dialogs::revolve::controls(app, ui, &mut d); + let state: Map = d + .fields + .iter() + .filter(|(k, _)| k.starts_with("__") && !matches!(k.as_str(), "__effect" | "__panel")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + set_pstate(&ctx, UI_STATE, state); + let after = crate::dialogs::form::params(&d); + let changed = changed_settings(&before, &after); + let dragging = ctx.input(|i| i.pointer.any_down()); + send(app, &ctx, changed, dragging); +} + +/// Sets to null each setting in `fields` (the first selected object's 3D effect) that the 3D effect +/// of another selected object has with another value. The controls then show it at its default +/// (a checkbox shows a dash) with its reset dot filled, and its reset dot, a menu choice, a checkbox click, or a number other +/// than the default shown is a change sent to every effect. The light list (`lights`, where null +/// is the single light) is left as it is. +pub(crate) fn mark_mixed(app: &VectorcraftApp, fields: &mut Map) { + let Some(st) = app.session.active() else { return }; + for other in st.selection.objects.iter().skip(1).filter_map(|id| find(st.doc.node(*id)?)) { + let other = vectorcraft_effects::merged_params(&other.effect, &other.params); + for (key, value) in fields.iter_mut().filter(|(k, _)| k.as_str() != "lights") { + if other.get(key.as_str()).is_some_and(|v| v != value) { + *value = Value::Null; + } + } + } +} + +/// The settings whose values differ between `before` and `after`, with their values in `after`. +pub(crate) fn changed_settings(before: &Value, after: &Value) -> Map { + let Some(after) = after.as_object() else { return Map::new() }; + after.iter().filter(|(k, v)| before.get(k.as_str()) != Some(*v)).map(|(k, v)| (k.clone(), v.clone())).collect() +} + +#[cfg(test)] +#[path = "tests_three_d.rs"] +mod tests; diff --git a/crates/ui-egui/src/panels/transform/tests.rs b/crates/ui-egui/src/panels/transform/tests.rs index e066dffe8..a945f9ec1 100644 --- a/crates/ui-egui/src/panels/transform/tests.rs +++ b/crates/ui-egui/src/panels/transform/tests.rs @@ -426,6 +426,42 @@ fn use_registration_point_for_symbol_follows_a_transformed_instance() { } } +/// The reference point locator is one tab stop: from the widget before it, two presses of Tab reach +/// X, the first stopping on the whole locator; → on the locator moves the shared reference point to +/// the middle of the right side. +#[test] +fn the_reference_point_locator_is_one_tab_stop_and_arrows_move_it() { + use crate::panels::tests_appearance::{click, frame_events, text_rect}; + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 20, "y": 30, "width": 100, "height": 50})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "70 pt").center(), show); + let x = ctx.memory(|m| m.focused()); + assert!(x.is_some(), "the click put the focus in X"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + let mut press = |key| { + frame_events(&ctx, &mut app, vec![key], show); + frame_events(&ctx, &mut app, vec![], show); + ctx.memory(|m| m.focused()) + }; + let locator = press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + let rect = |id: Option| id.and_then(|id| ctx.read_response(id)).map(|r| r.rect); + let grid = rect(locator).unwrap_or(egui::Rect::NOTHING); + assert_eq!(grid.size(), egui::Vec2::splat(23.0), "Shift+Tab from X reaches the whole locator, not one of its squares"); + let before = press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + assert!(before.is_some() && rect(before).is_some_and(|r| !grid.contains_rect(r)), "a second Shift+Tab leaves the locator"); + assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), locator, "Tab from the widget before the locator reaches it"); + assert_eq!(press(key(egui::Key::Tab, egui::Modifiers::NONE)), x, "a second Tab reaches X"); + press(key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), None, "→ in X leaves the reference point alone"); + press(key(egui::Key::Tab, egui::Modifiers::SHIFT)); + press(key(egui::Key::ArrowRight, egui::Modifiers::NONE)); + assert_eq!(ctx.data(|d| d.get_temp::(egui::Id::unique("refpt"))), Some(5), "→ on the locator chose the middle of the right side"); +} + #[test] fn artboard_fields_keep_the_reference_point() { let r = Rect::new(0.0, 0.0, 300.0, 200.0); diff --git a/crates/ui-egui/src/plugin_runs.rs b/crates/ui-egui/src/plugin_runs.rs index 719eadc18..a1711ee8f 100644 --- a/crates/ui-egui/src/plugin_runs.rs +++ b/crates/ui-egui/src/plugin_runs.rs @@ -9,12 +9,18 @@ //! ([`FilterRun::hand_in`]), so the edit is one undo step like any run, unless the document or the //! plug-in changed meanwhile: then nothing changes and the status bar says to run it again. On the //! web (no threads) it runs at once ([`available`]). +//! +//! A filter of plug-in API level 2 may report how far it has got (`vc_progress`): the progress +//! window then shows a bar with the share done. Its Cancel also makes the filter's `vc_cancelled` +//! return 1, so a filter that checks it stops at once; one that doesn't stops at the next slice of +//! its budget. +use std::sync::Arc; use std::sync::mpsc::{Receiver, TryRecvError, channel}; use serde_json::{Value, json}; -use vectorcraft_doc::cancel::{self, CancelToken}; use vectorcraft_engine::EngineError; +use vectorcraft_engine::cmd::fileio::progress::{self, Progress}; use vectorcraft_engine::cmd::plugin::FilterRun; use crate::VectorcraftApp; @@ -34,8 +40,8 @@ pub struct Running { who: String, /// `plugin.run`'s params that apply its output: the objects it was given as `ids`. params: Value, - /// Its Cancel. - token: CancelToken, + /// How far it has got, as it reports it, and its Cancel. + pub progress: Arc, /// Continue in Background: the progress window no longer shows it. pub hidden: bool, done: Receiver, @@ -44,7 +50,7 @@ pub struct Running { impl Running { /// Does the progress window show it: neither hidden (Continue in Background) nor cancelled? fn shown(&self) -> bool { - !self.hidden && !self.token.is_cancelled() + !self.hidden && !self.progress.cancelled() } } @@ -72,12 +78,12 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result { let mut params = p; params["ids"] = json!(run.inputs().iter().map(|i| i.0).collect::>()); let (tx, done) = channel(); - let token = CancelToken::new(); - let stop = token.clone(); + let shown = Progress::new(); + let reporting = Arc::clone(&shown); std::thread::Builder::new() .name("vectorcraft-plugin".into()) .spawn(move || { - let output = vectorcraft_engine::guard::catch_panic(|| cancel::scope(&stop, None, || run.run())) + let output = vectorcraft_engine::guard::catch_panic(|| progress::scope(&reporting, || run.run())) .map_err(|msg| format!("internal error: {msg} (please report this bug)")) .and_then(|r| r.map_err(|e| e.to_string())); // The app may have quit meanwhile: nothing to hand it to. @@ -87,7 +93,7 @@ pub fn start(app: &mut VectorcraftApp, p: Value) -> Result { let label = format!("Running {who}…"); let runs = &mut app.plugin_runs; runs.next_id += 1; - runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, token, hidden: false, done }); + runs.running.push(Running { id: runs.next_id, label: label.clone(), who, params, progress: shown, hidden: false, done }); app.status(label.clone()); Ok(json!({ "background": true, "status": label })) } @@ -121,7 +127,7 @@ pub fn poll(app: &mut VectorcraftApp) { /// they were; else why it has none, in the status bar. fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { // Cancel changes nothing, even when the run ended just before it. - if r.token.is_cancelled() { + if r.progress.cancelled() { app.status(EngineError::Cancelled.to_string()); return; } @@ -146,12 +152,12 @@ fn finish(app: &mut VectorcraftApp, r: Running, done: Option) { } } -/// The progress window's Cancel (also Esc): every heavy filter running stops at the next slice of -/// its budget and changes nothing → `{cancelled: n}`. +/// The progress window's Cancel (also Esc): every heavy filter running stops (at once when it checks +/// `vc_cancelled`, else at the next slice of its budget) and changes nothing → `{cancelled: n}`. pub fn cancel_all(app: &mut VectorcraftApp) -> Value { let mut n = 0; - for r in app.plugin_runs.running.iter().filter(|r| !r.token.is_cancelled()) { - r.token.cancel(); + for r in app.plugin_runs.running.iter().filter(|r| !r.progress.cancelled()) { + r.progress.cancel(); n += 1; } json!({ "cancelled": n }) @@ -170,7 +176,7 @@ pub fn hide_all(app: &mut VectorcraftApp) -> Value { /// Is a heavy filter running that hasn't been cancelled? pub fn any_running(app: &VectorcraftApp) -> bool { - app.plugin_runs.running.iter().any(|r| !r.token.is_cancelled()) + app.plugin_runs.running.iter().any(|r| !r.progress.cancelled()) } /// Is a heavy filter running that the progress window shows? @@ -195,7 +201,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { return; } let heading = crate::i18n::msg(&first.label); - let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| export_progress::busy(ui, tl!("The plug-in is computing…"))); + let reported = first.progress.fraction(); + let pressed = export_progress::window(ctx, "plugin-run-progress", &heading, |ui| match reported { + Some(share) => export_progress::share(ui, tl!("The plug-in is computing…"), share as f32), + None => export_progress::busy(ui, tl!("The plug-in is computing…")), + }); let id = match pressed { Some(Pressed::Cancel) => "plugin.cancelRuns", Some(Pressed::Hide) => "plugin.hideRuns", diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs index 20b9b2e82..c3532da83 100644 --- a/crates/ui-egui/src/prefs_dialog.rs +++ b/crates/ui-egui/src/prefs_dialog.rs @@ -32,9 +32,158 @@ pub fn open(app: &mut VectorcraftApp, category: Option<&str>) { fields.insert("__smartGuides".into(), json!(app.ui.view.smart_guides)); fields.insert("__snapToGrid".into(), json!(app.ui.view.snap_to_grid)); fields.insert("__taskBar".into(), json!(app.ui.task_bar)); + fields.insert("__search".into(), json!("")); + fields.insert("__focusSearch".into(), json!(true)); app.ui.dialog = Some(Dialog { kind: "preferences".into(), fields }); } +/// A setting the dialog shows, by its key, category, section ("" for none) and label, in English. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Setting { + pub key: &'static str, + pub category: &'static str, + pub section: &'static str, + pub label: &'static str, +} + +/// Every setting the dialog shows on this platform: the UI-only fields, then the engine's +/// preferences, each category's in the order the dialog lists them. +fn settings() -> impl Iterator { + let ui_fields = UI_FIELDS.iter().map(|&(key, category, label)| Setting { key, category, section: "", label }); + let shown = |key: &str| match key { + "systemTitleBar" => cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))), + "linuxDisplayServer" => cfg!(target_os = "linux"), + _ => true, + }; + let prefs = + PREF_SPECS.iter().filter(move |s| shown(s.key)).map(|s| Setting { key: s.key, category: s.category, section: s.section, label: s.label }); + ui_fields.chain(prefs) +} + +/// The settings whose label, section or category contains `query`, ignoring case, in the language +/// the dialog is drawn in or in English, grouped by category in the dialog's order. An empty +/// `query` finds none. +pub(crate) fn search(query: &str) -> Vec { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return vec![]; + } + let hit = |s: &str| !s.is_empty() && (tl!(s).to_lowercase().contains(&q) || s.to_lowercase().contains(&q)); + let mut found: Vec = settings().filter(|s| hit(s.label) || hit(s.section) || hit(s.category)).collect(); + found.sort_by_key(|s| PREF_CATEGORIES.iter().position(|c| *c == s.category).unwrap_or(usize::MAX)); + found +} + +/// Goes to the setting `s` a search found: its category is shown, the list scrolls to it and its +/// row is highlighted. The search field is cleared. +fn go_to(d: &mut Dialog, s: &Setting) { + d.fields.insert("__category".into(), json!(s.category)); + d.fields.insert("__found".into(), json!(s.key)); + d.fields.insert("__scrollTo".into(), json!(true)); + d.fields.insert("__search".into(), json!("")); +} + +/// The search field's memory key ([`widgets::search_field`]). +fn search_id() -> egui::Id { + egui::Id::unique("prefs-search") +} + +/// The search field's text edit, which takes the keyboard focus. +pub(crate) fn search_edit_id() -> egui::Id { + search_id().with("edit") +} + +/// Whether the search field had the keyboard focus when the dialog was last drawn. +fn search_focused_id() -> egui::Id { + egui::Id::unique("prefs-search-focused") +} + +/// Escape while the Preferences dialog's search field has text and the keyboard focus: the field is +/// cleared and keeps the focus, and the Escape goes no further. → Whether it did; otherwise Escape +/// does what it does in any dialog. +pub(crate) fn clear_search_on_escape(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { + let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == "preferences") else { return false }; + // egui takes the focus from the field when the frame with Escape begins: the field's focus is + // the one recorded when the dialog was last drawn. + if d.str("__search").is_empty() || !ctx.data(|m| m.get_temp::(search_focused_id()).unwrap_or(false)) { + return false; + } + d.fields.insert("__search".into(), json!("")); + d.fields.insert("__focusSearch".into(), json!(true)); + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }))); + true +} + +/// The search field above the category list. It takes the keyboard focus when the dialog opens and +/// on Cmd/Ctrl+F. → Its text. +fn search_box(ui: &mut egui::Ui, d: &mut Dialog) -> String { + let refocus = ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::F)); + if refocus { + d.fields.insert("__focusSearch".into(), json!(true)); + } + ui.data_mut(|m| m.insert_temp(search_id(), d.str("__search"))); + let query = widgets::search_field(ui, search_id(), tl!("Search Preferences")); + if d.bool("__focusSearch") { + // Not on the window's measuring frame, nor in the frame of the click that opened the + // dialog: egui takes the focus from a field such a click missed. + if ui.is_sizing_pass() || ui.input(|i| i.pointer.any_click()) { + ui.ctx().request_repaint(); + } else { + widgets::focus_field(ui, search_edit_id(), &query); + d.fields.remove("__focusSearch"); + } + } + let focused = ui.memory(|m| m.has_focus(search_edit_id())); + ui.data_mut(|m| m.insert_temp(search_focused_id(), focused)); + d.fields.insert("__search".into(), json!(query)); + query +} + +/// The settings `query` finds, in place of the category list: choosing one goes to it. +fn search_results(ui: &mut egui::Ui, d: &mut Dialog, query: &str) { + let t = Tokens::get(ui.ctx()); + let found = search(query); + if found.is_empty() { + ui.label(egui::RichText::new(tl!("No settings found")).color(t.text_dim).size(12.0)); + return; + } + egui::ScrollArea::vertical().id_salt("prefs-search-results").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Truncate); + for s in &found { + let text = egui::RichText::new(tl!(s.label)).size(12.5).color(t.text); + let place = if s.section.is_empty() { tl!(s.category).to_string() } else { format!("{} › {}", tl!(s.category), tl!(s.section)) }; + let b = egui::Button::selectable(false, text).min_size(egui::vec2(180.0, 22.0)); + if ui.add(b).on_hover_text(&place).clicked() { + go_to(d, s); + } + ui.label(egui::RichText::new(place).color(t.text_dim).size(11.0)); + ui.add_space(3.0); + } + }); +} + +/// Before a setting's row: where to paint the highlight behind it, when it is the setting a search +/// went to ([`go_to`]). +fn found_mark(ui: &mut egui::Ui, d: &Dialog, key: &str) -> Option<(egui::layers::ShapeIdx, f32)> { + (d.str("__found") == key).then(|| (ui.painter().add(egui::Shape::Noop), ui.cursor().top())) +} + +/// After the row [`found_mark`] marked: highlights it, and scrolls the list to it once. +fn found_paint(ui: &mut egui::Ui, d: &mut Dialog, mark: Option<(egui::layers::ShapeIdx, f32)>) { + let Some((idx, top)) = mark else { return }; + let bottom = ui.min_rect().bottom(); + if bottom <= top { + return; + } + let rect = egui::Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=bottom).expand2(egui::vec2(0.0, 2.0)); + let t = Tokens::get(ui.ctx()); + ui.painter().set(idx, egui::epaint::RectShape::filled(rect, 3.0, t.accent.gamma_multiply(0.22))); + if d.bool("__scrollTo") && !ui.is_sizing_pass() { + ui.scroll_to_rect(rect, Some(egui::Align::Center)); + d.fields.remove("__scrollTo"); + } +} + /// OK: validate and apply every preference in the dialog (nothing changes on error). pub fn confirm(app: &mut VectorcraftApp) -> Result { let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) }; @@ -189,20 +338,32 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { crate::dialogs::modal::heading(ui, tl!("Preferences")); ui.add_space(12.0); ui.horizontal_top(|ui| { - // Category list. - egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)).show(ui, |ui| { - ui.set_width(196.0); - ui.set_min_height(430.0); - ui.vertical(|ui| { - ui.spacing_mut().item_spacing.y = 1.0; - for c in PREF_CATEGORIES { - let sel = *c == cat; - let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text }); - let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0)); - if ui.add(b).clicked() { - d.fields.insert("__category".into(), json!(c)); + ui.vertical(|ui| { + ui.set_width(208.0); + // Search, above the category list; while it has text, the settings it finds show + // in place of the list. + let query = search_box(ui, &mut d); + ui.add_space(6.0); + // Category list. + let list = egui::Frame::NONE.fill(t.panel_darker).corner_radius(egui::CornerRadius::same(4)).inner_margin(egui::Margin::same(6)); + list.show(ui, |ui| { + ui.set_width(196.0); + ui.set_min_height(396.0); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 1.0; + if !query.trim().is_empty() { + search_results(ui, &mut d, &query); + return; } - } + for c in PREF_CATEGORIES { + let sel = *c == cat; + let text = egui::RichText::new(tl!(*c)).size(12.5).color(if sel { t.text_strong } else { t.text }); + let b = egui::Button::selectable(sel, text).min_size(egui::vec2(184.0, 24.0)); + if ui.add(b).clicked() { + d.fields.insert("__category".into(), json!(c)); + } + } + }); }); }); ui.add_space(14.0); @@ -248,6 +409,12 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { d.fields.insert(k, v); } } + // The setting a search went to stays highlighted until another category is shown. + let found = d.str("__found"); + if !found.is_empty() && settings().find(|s| s.key == found).is_none_or(|s| s.category != d.str("__category")) { + d.fields.remove("__found"); + d.fields.remove("__scrollTo"); + } app.ui.dialog = Some(d); if cancel { app.ui.dialog = None; @@ -263,11 +430,15 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, let specs: Vec<_> = PREF_SPECS.iter().filter(|s| s.category == cat).collect(); for (key, c, label) in UI_FIELDS { if *c == cat && cat != "Smart Guides" { + let mark = found_mark(ui, d, key); bool_row(ui, d, key, label); + found_paint(ui, d, mark); } } if cat == "Smart Guides" { + let mark = found_mark(ui, d, "__smartGuides"); bool_row(ui, d, "__smartGuides", UI_FIELDS[0].2); + found_paint(ui, d, mark); ui.add_space(4.0); } for sp in specs { @@ -283,6 +454,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, } first = false; let v = d.fields.get(sp.key).cloned().unwrap_or(Value::Null); + let mark = found_mark(ui, d, sp.key); match sp.kind { // Units › Numbers Without Units Are Points only tells points from picas: dimmed // unless a unit is Picas. @@ -409,6 +581,7 @@ fn category_fields(ui: &mut egui::Ui, app: &mut VectorcraftApp, d: &mut Dialog, }); } } + found_paint(ui, d, mark); } } diff --git a/crates/ui-egui/src/prefs_dialog/tests.rs b/crates/ui-egui/src/prefs_dialog/tests.rs index 8d0285254..a43ec6677 100644 --- a/crates/ui-egui/src/prefs_dialog/tests.rs +++ b/crates/ui-egui/src/prefs_dialog/tests.rs @@ -324,6 +324,157 @@ fn the_category_shown_is_highlighted() { assert_eq!(filled, vec![3], "{rows:?}"); } +/// One frame of the Preferences dialog with `events`, keys first as the app takes them. +fn search_frame(a: &mut VectorcraftApp, ctx: &egui::Context, events: Vec) -> egui::FullOutput { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + crate::shortcuts::handle(a, ui.ctx()); + show(a, ui.ctx()); + }); + out.textures_delta.clear(); + out +} + +/// A key pressed and let go in one frame. +fn key_press(key: egui::Key, modifiers: egui::Modifiers) -> Vec { + [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec() +} + +fn search_text(a: &VectorcraftApp) -> String { + a.ui.dialog.as_ref().map(|d| d.str("__search")).unwrap_or_default() +} + +/// Preferences' search finds a setting by its label, ignoring case; an empty search finds nothing. +#[test] +fn a_search_finds_a_setting_by_its_label() { + let keys = |q: &str| search(q).iter().map(|s| s.key).collect::>(); + assert!(keys("tool tips").contains(&"showToolTips"), "{:?}", keys("tool tips")); + assert!(keys("SHOW TOOL TIPS").contains(&"showToolTips")); + assert!(keys("snap to grid").contains(&"__snapToGrid")); + let found = search("gridline every"); + assert_eq!(found.iter().map(|s| (s.key, s.category)).collect::>(), [("gridlineEvery", "Guides & Grid")]); + assert!(search("").is_empty() && search(" ").is_empty()); + assert!(search("no such setting at all").is_empty()); +} + +/// A translated label matches while the dialog is drawn in that language. +#[test] +fn a_translated_label_matches_in_the_interface_language() { + let de = crate::i18n::Lang::from_code("de").expect("de registered"); + let label = crate::i18n::tr(de, "Show Tool Tips"); + assert_ne!(label, "Show Tool Tips", "German has a row for the label"); + crate::i18n::set_current(de); + let found: Vec<_> = search(&label.to_uppercase()).iter().map(|s| s.key).collect(); + crate::i18n::set_current(crate::i18n::Lang::EN); + assert!(found.contains(&"showToolTips"), "{label}: {found:?}"); +} + +/// An empty search field shows the category list; a search that finds nothing says so. +#[test] +fn an_empty_search_lists_the_categories_and_no_match_says_so() { + let mut a = app(); + open(&mut a, Some("General")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(PREF_CATEGORIES.iter().all(|c| text.contains(c)), "{text}"); + assert!(!text.contains("No settings found"), "{text}"); + a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!("no such setting at all")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(text.contains("No settings found"), "{text}"); + assert!(!text.contains("Plug-ins & Scratch Disks"), "the list is hidden: {text}"); + a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!(" ")); + let text = crate::tests_labels::painted_text(&mut a, |a, ui| show(a, ui.ctx())); + assert!(PREF_CATEGORIES.iter().all(|c| text.contains(c)), "{text}"); +} + +/// Choosing a setting the search found shows its category, clears the search and highlights the +/// setting's row. +#[test] +fn choosing_a_found_setting_goes_to_its_category() { + let mut a = app(); + open(&mut a, Some("General")); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + a.ui.dialog.as_mut().unwrap().fields.insert("__search".into(), json!("gridline")); + search_frame(&mut a, &ctx, vec![]); + let out = search_frame(&mut a, &ctx, vec![]); + let mut at = None; + fn find(s: &egui::Shape, label: &str, at: &mut Option) { + match s { + egui::Shape::Text(t) if t.galley.text() == label => *at = Some(t.pos + t.galley.rect.center().to_vec2()), + egui::Shape::Vec(v) => v.iter().for_each(|s| find(s, label, at)), + _ => {} + } + } + out.shapes.iter().for_each(|c| find(&c.shape, "Gridline every", &mut at)); + let at = at.expect("the found setting is listed"); + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + search_frame(&mut a, &ctx, vec![egui::Event::PointerMoved(at), button(true)]); + search_frame(&mut a, &ctx, vec![button(false)]); + let d = a.ui.dialog.as_ref().unwrap(); + assert_eq!((d.str("__category").as_str(), d.str("__found").as_str(), d.str("__search").as_str()), ("Guides & Grid", "gridlineEvery", "")); + search_frame(&mut a, &ctx, vec![]); + let out = search_frame(&mut a, &ctx, vec![]); + fn rects(s: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) { + match s { + egui::Shape::Rect(r) => out.push((r.rect, r.fill)), + egui::Shape::Vec(v) => v.iter().for_each(|s| rects(s, out)), + _ => {} + } + } + let mut shown = vec![]; + out.shapes.iter().for_each(|c| rects(&c.shape, &mut shown)); + let mut label = None; + out.shapes.iter().for_each(|c| find(&c.shape, "Gridline every:", &mut label)); + let label = label.expect("the setting's row is shown"); + // A tinted band across the fields column, behind the setting's label. + let band = shown.iter().any(|(r, fill)| r.width() >= 500.0 && r.height() < 40.0 && fill.a() > 0 && r.contains(label)); + assert!(band, "the setting's row is highlighted: {shown:?}"); + assert!(!a.ui.dialog.as_ref().unwrap().bool("__scrollTo"), "the list scrolled to it"); + // Another category: the highlight goes. + a.ui.dialog.as_mut().unwrap().fields.insert("__category".into(), json!("Units")); + search_frame(&mut a, &ctx, vec![]); + assert_eq!(a.ui.dialog.as_ref().unwrap().str("__found"), ""); +} + +/// The search field has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back. +#[test] +fn the_search_field_takes_the_focus_on_open_and_on_cmd_f() { + let mut a = app(); + open(&mut a, None); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let focused = |ctx: &egui::Context| ctx.memory(|m| m.has_focus(search_edit_id())); + search_frame(&mut a, &ctx, vec![]); + search_frame(&mut a, &ctx, vec![]); + assert!(focused(&ctx), "focused on open"); + ctx.memory_mut(|m| m.surrender_focus(search_edit_id())); + search_frame(&mut a, &ctx, vec![]); + assert!(!focused(&ctx)); + search_frame(&mut a, &ctx, key_press(egui::Key::F, egui::Modifiers::COMMAND)); + assert!(focused(&ctx), "focused on Cmd/Ctrl+F"); + assert!(a.ui.dialog.is_some()); +} + +/// Escape in the search field with text clears it and keeps the dialog open with the field +/// focused; Escape in the empty field closes the dialog. +#[test] +fn escape_clears_the_search_then_closes_the_dialog() { + let mut a = app(); + open(&mut a, None); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + search_frame(&mut a, &ctx, vec![]); + search_frame(&mut a, &ctx, vec![]); + search_frame(&mut a, &ctx, vec![egui::Event::Text("grid".into())]); + assert_eq!(search_text(&a), "grid"); + search_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE)); + assert!(a.ui.dialog.is_some(), "the first Escape leaves the dialog open"); + assert_eq!(search_text(&a), ""); + search_frame(&mut a, &ctx, vec![]); + assert!(ctx.memory(|m| m.has_focus(search_edit_id())), "the field keeps the focus"); + search_frame(&mut a, &ctx, key_press(egui::Key::Escape, egui::Modifiers::NONE)); + assert!(a.ui.dialog.is_none(), "Escape in the empty field closes the dialog"); +} + /// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it /// changes, and its checkbox is dimmed unless a unit is Picas. #[test] diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs index 3836f3f18..d5ed1ade2 100644 --- a/crates/ui-egui/src/shortcut_editor.rs +++ b/crates/ui-egui/src/shortcut_editor.rs @@ -453,8 +453,8 @@ fn listed(overrides: &Overrides, e: &Entry) -> bool { /// Commands whose params are all optional that do nothing, or fail, when a key press runs them /// with none, and that no item of the menu bar runs: Transparency and Opacity Mask Options need /// a value to set, Align and Distribute a direction, Delete Swatch and Move Swatch a swatch name, -/// and the library and data set loaders a file, which their panel menu items ask for. -const NEEDS_A_PARAM: [&str; 16] = [ +/// and the library, data set and text style loaders a file, which their panel menu items ask for. +const NEEDS_A_PARAM: [&str; 19] = [ "transparency.set", "transparency.setOpacityMask", "stroke.set", @@ -471,6 +471,9 @@ const NEEDS_A_PARAM: [&str; 16] = [ "brush.loadLibrary", "dataset.import", "variable.loadLibrary", + "charStyle.load", + "paraStyle.load", + "textStyle.loadAll", ]; /// The (label, params) of each menu bar item, by command id. @@ -743,22 +746,35 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result { Ok(json!({"overrides": app.ui.shortcut_overrides.len()})) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out or gives null. Any other +/// value, such as `5`, is an error: "`{key}` must be a string". +fn text_param(p: &Value, key: &str) -> Result, String> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Ok(Some(v.clone())), + Some(_) => Err(format!("`{key}` must be a string")), + } +} + /// `shortcuts.*` UI commands. `None` = not ours. pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { - let s = |k: &str| p.get(k).and_then(Value::as_str).map(str::to_string); + let s = |k: &str| text_param(p, k); let r = match id { "edit.keyboardShortcuts" => { open(app); Ok(Value::Null) } "shortcuts.set" => { - let key = s("id").unwrap_or_default(); + let (key, shortcut, force) = match (s("id"), s("shortcut"), crate::menus::opt_bool(p, "force")) { + (Ok(key), Ok(shortcut), Ok(force)) => (key.unwrap_or_default(), shortcut, force.unwrap_or(false)), + (Err(e), _, _) | (_, Err(e), _) | (_, _, Err(e)) => return Some(Err(e)), + }; let mut ov = app.ui.shortcut_overrides.clone(); let r = if p.get("shortcut").is_some_and(Value::is_null) { reset_one(&mut ov, &key); Ok(vec![]) } else { - assign(&mut ov, &key, s("shortcut").as_deref(), p.get("force").and_then(Value::as_bool).unwrap_or(false)) + assign(&mut ov, &key, shortcut.as_deref(), force) }; r.map(|removed| { app.ui.shortcut_overrides = ov; @@ -768,7 +784,10 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option { - let q = s("query").unwrap_or_default().to_lowercase(); + let q = match s("query") { + Ok(q) => q.unwrap_or_default().to_lowercase(), + Err(e) => return Some(Err(e)), + }; let km = keymap_with(&app.ui.shortcut_overrides); let ov = km.overrides(); Ok(Value::Array( @@ -789,23 +808,28 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option match s("name").as_deref().map(set_name).and_then(|n| preset(n).map(|p| (n.to_string(), p))) { - Some((n, p)) => { + "shortcuts.preset" => match s("name").map(|n| n.as_deref().map(set_name).and_then(|n| preset(n).map(|p| (n.to_string(), p)))) { + Ok(Some((n, p))) => { app.ui.shortcut_overrides = p; app.ui.shortcut_set = n; sync(&app.ui); Ok(Value::Null) } - None => Err(format!("unknown preset (one of {})", PRESETS.join(", "))), + Ok(None) => Err(format!("unknown preset (one of {})", PRESETS.join(", "))), + Err(e) => Err(e), }, "shortcuts.export" => { + let path = match s("path") { + Ok(path) => path, + Err(e) => return Some(Err(e)), + }; let v = export_json(&app.ui.shortcut_set, &app.ui.shortcut_overrides); let bytes = serde_json::to_vec_pretty(&v).unwrap_or_default(); let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Shortcut sets", &["json"])], ..crate::FilePick::named("VectorCraft Shortcuts.json") }; - let path = s("path").or_else(|| crate::picks::save(app, &pick)); + let path = path.or_else(|| crate::picks::save(app, &pick)); match path { Some(path) => match app.services.write.as_mut() { Some(w) => w(&path, &bytes).map(|_| json!({"path": path})), @@ -848,11 +872,15 @@ pub fn run_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option path, + Err(e) => return Some(Err(e)), + }; let pick = crate::FilePick { filters: vec![crate::FilePick::filter("Shortcut sets", crate::dialogs::shortcut_import::IMPORT_EXTS)], ..Default::default() }; - let path = s("path").or_else(|| crate::picks::open(app, &pick)); + let path = path.or_else(|| crate::picks::open(app, &pick)); // A `.kys` file is read by Import Shortcut Set, up to its reader's cap. if let Some(path) = path.as_deref().filter(|p| crate::dialogs::shortcut_import::is_kys(p)) { return Some(crate::dialogs::shortcut_import::command(app, &json!({ "path": path }))); @@ -1827,4 +1855,28 @@ mod tests { assign(&mut ov, "object.isolate", Some("Cmd+Alt+Shift+F11"), false).unwrap(); assert_eq!(effective_in(&ov, "object.isolate").as_deref(), Some("Cmd+Alt+Shift+F11")); } + + /// `shortcuts.set`, `shortcuts.list` and `shortcuts.preset` reject a supplied param of the wrong + /// type with an error naming it, and change nothing; an omitted or null param keeps its default. + #[test] + fn shortcut_commands_reject_a_param_of_the_wrong_type() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let set = app.ui.shortcut_set.clone(); + for (id, p, err) in [ + ("shortcuts.set", json!({"id": 5, "shortcut": "Cmd+Alt+Shift+F11"}), "`id` must be a string"), + ("shortcuts.set", json!({"id": "object.isolate", "shortcut": 7}), "`shortcut` must be a string"), + ("shortcuts.set", json!({"id": "object.isolate", "shortcut": "Cmd+Alt+Shift+F11", "force": "yes"}), "`force` must be true or false"), + ("shortcuts.list", json!({"query": 5}), "`query` must be a string"), + ("shortcuts.preset", json!({"name": true}), "`name` must be a string"), + ] { + assert_eq!(app.run(id, p.clone()), Err(err.to_string()), "{id} {p}"); + assert!(app.ui.shortcut_overrides.is_empty(), "{id} {p}"); + assert_eq!(app.ui.shortcut_set, set, "{id} {p}"); + } + let all = app.run("shortcuts.list", json!({})).unwrap(); + assert_eq!(app.run("shortcuts.list", json!({"query": null})), Ok(all)); + let r = app.run("shortcuts.set", json!({"id": "object.isolate", "shortcut": "Cmd+Alt+Shift+F11", "force": null})).unwrap(); + assert_eq!(r["shortcut"], "Cmd+Alt+Shift+F11"); + assert!(app.run("shortcuts.preset", json!({"name": null})).unwrap_err().starts_with("unknown preset")); + } } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f6ceaec9d..713cf5dd4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -11,10 +11,12 @@ use vectorcraft_tools::{Mods, SurroundingUnit, ToolKey}; use crate::VectorcraftApp; +mod held_tool; mod paste; mod presentation; mod typing; +pub(crate) use held_tool::{HeldTool, note_tool_use}; use paste::unclip_chords; pub(crate) use paste::{PasteChord, paste_command}; pub(crate) use presentation::presentation_step; @@ -186,7 +188,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { } ctx.input(|i| crate::shortcut_editor::note_backtick_text(&i.events)); note_key_surfaces(app, ctx); - if !(ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT) { + let focused = ctx.input(|input| input.focused) || app.forwarded_frame && ctx.viewport_id() == egui::ViewportId::ROOT; + // A held tool shortcut that is let go returns to the tool active before it. + held_tool::follow(app, ctx, focused); + if !focused { crate::ime::controls_without_editor(app, ctx); return; } @@ -211,7 +216,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { // Escape closes a dropdown list or popover open in the dialog (each closes itself on it) and // leaves the dialog open. let popup = app.ui.dialog.as_ref().is_some_and(|d| egui::Popup::is_any_open(ctx) || crate::dialogs::brush_options::tips_open(d)); - if !popup && pressed_anew(ctx, Key::Escape) { + // Escape in Preferences' search field with text clears the field first. + if !popup && pressed_anew(ctx, Key::Escape) && !crate::prefs_dialog::clear_search_on_escape(app, ctx) { crate::dialogs::cancel(app); app.ui.palette_open = false; } @@ -236,8 +242,8 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { let composing = app.session.tool_composing(); // Tool keys first (Enter/Escape end paths; arrows change polygon sides while dragging). let busy = app.session.tool_busy(); - // Enter in the Type tool types a line break, so it goes to the tool with the frame's text, - // below: the loop then skips Enter, the first key. + // Enter in the Type tool types a line break (Cmd+Enter ends editing), so it goes to the tool + // with the frame's text, below: the loop then skips Enter, the first key. let enter_types = app.session.tool_wants_text(); for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)].into_iter().skip(usize::from(enter_types)) { if !typing && !composing && !take_claimed_repeat(app, ctx, k, tk, view) && pressed_anew(ctx, k) { @@ -316,6 +322,7 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) { ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)); } } + held_tool::note_enter_dialog(app, k); } } if typing { @@ -479,26 +486,26 @@ fn remember_escape_pair(ctx: &egui::Context, key: Key, escape_pair: Option fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // Single-key tool shortcuts (no Cmd/Ctrl/Alt). With a layout without Latin letters (Persian, // Arabic, Russian, Greek…) the key's position stands in for what it types (#793). - // A text's key is the press that typed it ([`typing_presses`]). - let events: Vec<(String, Modifiers, Option)> = ctx.input(|i| { + // A text's key is the press that typed it ([`typing_presses`]), and the text repeats with it. + let events: Vec<(String, Modifiers, Option, bool)> = ctx.input(|i| { let typed_by = typing_presses(&i.events); i.events .iter() .zip(typed_by) .filter_map(|(e, by)| match e { egui::Event::Text(t) => { - let (physical, key) = match by.and_then(|k| i.events.get(k)) { - Some(egui::Event::Key { key, physical_key, .. }) => (*physical_key, Some(*key)), - _ => (None, None), + let (physical, key, repeat) = match by.and_then(|k| i.events.get(k)) { + Some(egui::Event::Key { key, physical_key, repeat, .. }) => (*physical_key, Some(*key), *repeat), + _ => (None, None, false), }; - Some((latin(t, physical), i.modifiers, key)) + Some((latin(t, physical), i.modifiers, key, repeat)) } _ => None, }) .collect() }); let keymap = crate::shortcut_editor::current(); - for (text, m, pressed) in events { + for (text, m, pressed, repeat) in events { if m.command || m.alt || m.ctrl { continue; } @@ -520,7 +527,7 @@ fn single_key_shortcuts(app: &mut VectorcraftApp, ctx: &egui::Context) { // tools. let Some(hit) = hits.iter().find(|c| !c.id.starts_with("tool:")).or(hits.first()) else { continue }; match hit.id.strip_prefix("tool:") { - Some(tool) => app.select_tool(tool), + Some(tool) => held_tool::choose(app, ctx, tool, pressed, repeat), None if let Some((id, p)) = menu_target(hit) => crate::menus::invoke(app, &id, p), None if hit.params.is_null() => { let _ = app.run(&hit.id, json!({})); diff --git a/crates/ui-egui/src/shortcuts/held_tool.rs b/crates/ui-egui/src/shortcuts/held_tool.rs new file mode 100644 index 000000000..a3a809b96 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool.rs @@ -0,0 +1,154 @@ +//! A single-key tool shortcut held down (General › Hold a Tool Shortcut to Use the Tool +//! Temporarily, `holdToolKeys`): the tool it chose stays until the key is let go, and then the tool +//! active before comes back. A quick press chooses the tool for good. + +use super::{Key, VectorcraftApp}; + +/// A key let go this many seconds or more after its press returns to the tool active before it. +/// VectorCraft's own value. +pub(crate) const HOLD_SECONDS: f64 = 0.25; + +/// A tool shortcut's key held down since it chose its tool. +pub(crate) struct HeldTool { + /// The key that chose the tool. + key: Key, + /// The viewport that saw the press: other viewports do not see the key. + viewport: egui::ViewportId, + /// The tool the key chose. + tool: &'static str, + /// The tool to go back to. + previous: &'static str, + /// When the key went down (`egui::InputState::time`). + since: f64, + /// The tool took a drag, a click or a dialog while the key was down. + used: bool, + /// When a frame first saw the key let go. The decision can come in a later frame (after a + /// drag, a dialog or a press of the same frame), and it uses this time. + released: Option, +} + +/// Chooses `tool` for a press of its single-key shortcut `key` (`None`: no key press typed it). A +/// press that changes the tool, with the preference on, starts a hold. A hold started while another +/// key is held goes on top of it: letting go of the later key returns to the earlier key's tool, +/// and letting go of the earlier key then returns to the tool before both. With the preference on, +/// a key's repeats choose nothing: a key held down chooses its tool once. With it off, each repeat +/// chooses the tool again. +pub(super) fn choose(app: &mut VectorcraftApp, ctx: &egui::Context, tool: &str, key: Option, repeat: bool) { + if repeat && app.session.prefs.hold_tool_keys { + return; + } + let previous = app.session.tool_id(); + // `select_tool` ends the holds of a tool chosen any other way; the keys held stay held here. + let holds = std::mem::take(&mut app.held_tool); + app.select_tool(tool); + app.held_tool = holds; + let chosen = app.session.tool_id(); + if chosen == previous { + return; + } + let viewport = ctx.viewport_id(); + let (down, now) = key.map_or((false, 0.0), |k| ctx.input(|i| (i.key_down(k), i.time))); + match key { + // A key let go in the frame that pressed it was a quick press. + Some(key) if down && app.session.prefs.hold_tool_keys && chosen == tool => { + app.held_tool.retain(|h| h.viewport == viewport && h.key != key); + app.held_tool.push(HeldTool { key, viewport, tool: chosen, previous, since: now, used: false, released: None }); + } + _ => app.held_tool.clear(), + } +} + +/// A press that reaches the active tool, a drag of the Zoom, Hand or Rotate View tool, or the +/// tool's dialog opened with Enter, uses the tool of the key held last when that tool is active, +/// also when that and the key's release come between two frames of [`follow`]. +pub(crate) fn note_tool_use(app: &mut VectorcraftApp) { + let active = app.session.tool_id(); + if let Some(held) = app.held_tool.last_mut().filter(|h| h.tool == active) { + held.used = true; + } +} + +/// After the tool takes `key`: a dialog that Enter left open is the tool's, so it uses the tool. +pub(super) fn note_enter_dialog(app: &mut VectorcraftApp, key: Key) { + if key == Key::Enter && app.ui.dialog.is_some() { + note_tool_use(app); + } +} + +/// Once per frame of the viewport that saw the press: when the key held last is let go, goes back +/// to the tool active before it, unless the press was quick and the tool was not used. A key let +/// go during a drag, with the button held or with a dialog open, goes back once that ends. Choosing +/// another tool, a quick press, turning the preference off or the window losing the keyboard ends +/// every hold without going back. A frame of another viewport that has the keyboard also ends them. +pub(super) fn follow(app: &mut VectorcraftApp, ctx: &egui::Context, focused: bool) { + // Every key let go is timed in the frame that first sees it up, under the key held last too. + let (viewport, time) = (ctx.viewport_id(), ctx.input(|i| i.time)); + for held in app.held_tool.iter_mut().filter(|h| h.viewport == viewport && h.released.is_none()) { + if !ctx.input(|i| i.key_down(held.key)) { + held.released = Some(time); + } + } + // Each pass looks at the key held last; one that goes back hands over to the key under it. + loop { + let gesture = app.session.tool_busy() || app.session.pointer_held(); + // A dialog, or a canvas drag that does not reach the tool (a pan, zoom or turn of the view), + // defers the decision. + let dialog = app.ui.dialog.is_some() || crate::canvas::dragging(ctx); + let active = app.session.tool_id(); + let on = app.session.prefs.hold_tool_keys; + let Some(held) = app.held_tool.last_mut() else { return }; + if !on { + app.held_tool.clear(); + return; + } + if held.viewport != ctx.viewport_id() { + // This viewport has the keyboard, so the viewport that saw the press lost it. + if focused { + app.held_tool.clear(); + } + return; + } + if !focused { + app.held_tool.clear(); + return; + } + let (down, now) = ctx.input(|i| (i.key_down(held.key), i.time)); + if gesture || dialog { + // A gesture of the held tool with the key down uses the tool; a gesture lent to a + // selection tool does not. A dialog uses it only when Enter opened it, and a view drag + // when it is the held tool's ([`note_tool_use`]). + held.used |= gesture && down && active == held.tool; + return; + } + if active != held.tool { + app.held_tool.clear(); + return; + } + if down { + return; + } + // The canvas takes a pointer press of the same frame, and the tool an Enter press, after + // this: the decision waits for the next frame, measured from this one. + let pressed = ctx.input(|i| { + i.events + .iter() + .any(|e| matches!(e, egui::Event::PointerButton { pressed: true, .. } | egui::Event::Key { key: Key::Enter, pressed: true, .. })) + }); + let released = held.released.unwrap_or(now); + if pressed && released == now { + ctx.request_repaint(); + return; + } + let back = held.used || released - held.since >= HOLD_SECONDS; + let previous = held.previous; + if !back { + app.held_tool.clear(); + return; + } + app.held_tool.pop(); + app.return_to_tool(previous); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ui-egui/src/shortcuts/held_tool/tests.rs b/crates/ui-egui/src/shortcuts/held_tool/tests.rs new file mode 100644 index 000000000..e2217b822 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/held_tool/tests.rs @@ -0,0 +1,462 @@ +use serde_json::json; +use vectorcraft_tools::{PointerEvent, PointerKind}; + +use super::super::handle; +use super::*; + +/// A keyboard on one context, so egui keeps the keys held down from frame to frame and marks a +/// held key's later presses as repeats, with the frame times the test sets. +struct Keyboard { + ctx: egui::Context, +} + +impl Keyboard { + fn new() -> Self { + Self { ctx: egui::Context::default() } + } + + /// One frame at `time` seconds delivering `events`; with `field`, a text field that takes the + /// keyboard is drawn after the shortcuts. + fn frame(&self, app: &mut VectorcraftApp, time: f64, events: Vec, field: Option<&mut String>) { + self.frame_with_focus(app, time, events, field, true); + } + + /// [`Self::frame`], with the window having the keyboard (`focused`) or not. + fn frame_with_focus(&self, app: &mut VectorcraftApp, time: f64, events: Vec, mut field: Option<&mut String>, focused: bool) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + if let Some(text) = field.as_deref_mut() { + ui.add(egui::TextEdit::singleline(text).id(egui::Id::unique("held-tool-field"))).request_focus(); + } + }); + out.textures_delta.clear(); + } + + /// One frame at `time` seconds delivering `events`, with the canvas drawn after the shortcuts. + fn on_canvas(&self, app: &mut VectorcraftApp, time: f64, events: Vec) { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))); + let mut out = self.ctx.run_ui(egui::RawInput { events, screen_rect, time: Some(time), focused: true, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + handle(app, ui.ctx()); + crate::canvas::show(app, ui); + }); + out.textures_delta.clear(); + } + + /// `key` pressed (or repeated while it is down) at `time`, typing `letter`. + fn press(&self, app: &mut VectorcraftApp, time: f64, key: Key, letter: &str) { + let press = egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![press, egui::Event::Text(letter.into())], None); + } + + fn release(&self, app: &mut VectorcraftApp, time: f64, key: Key) { + let release = egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + self.frame(app, time, vec![release], None); + } + + fn press_s(&self, app: &mut VectorcraftApp, time: f64) { + self.press(app, time, Key::S, "s"); + } + + fn release_s(&self, app: &mut VectorcraftApp, time: f64) { + self.release(app, time, Key::S); + } +} + +/// A document with a selected rectangle and the Pen tool chosen. +fn pen_app() -> VectorcraftApp { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + crate::shortcut_editor::sync(&app.ui); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 50})).unwrap(); + app.select_tool("pen"); + app +} + +/// S held from 0 s, its repeats at `repeats`, let go at `release`. +fn hold_s(app: &mut VectorcraftApp, keys: &Keyboard, repeats: &[f64], release: f64) { + keys.press_s(app, 0.0); + assert_eq!(app.session.tool_id(), "scale", "the press chooses Scale"); + for &t in repeats { + keys.press_s(app, t); + assert_eq!(app.session.tool_id(), "scale", "a repeat at {t} s keeps Scale"); + } + keys.release_s(app, release); +} + +/// A quick press of S chooses Scale for good. +#[test] +fn a_quick_press_of_a_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[], HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale"); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays chosen"); + assert!(app.held_tool.is_empty()); +} + +/// A press and release of S in one frame is a quick press, also when the next frame comes long +/// after it. +#[test] +fn a_press_and_release_in_one_frame_chooses_the_tool_for_good() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let key = |pressed| egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.0, vec![key(true), egui::Event::Text("s".into()), key(false)], None); + keys.frame(&mut app, 5.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S held past the threshold, with its key repeats, and let go returns to the Pen. +#[test] +fn a_held_tool_key_returns_to_the_previous_tool_when_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// S held from the Pen and then V held: letting go of V returns to Scale, and letting go of S +/// then returns to the Pen. +#[test] +fn overlapping_held_tool_keys_return_one_at_a_time() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.1, Key::V, "v"); + assert_eq!(app.session.tool_id(), "selection"); + keys.release(&mut app, 0.4, Key::V); + assert_eq!(app.session.tool_id(), "scale", "letting go of V returns to Scale while S is down"); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "pen", "letting go of S returns to the Pen"); + assert!(app.held_tool.is_empty()); +} + +/// S let go during a Scale drag returns to the Pen once the drag ends, also when the key was let go +/// before the threshold. +#[test] +fn a_tool_key_let_go_during_a_drag_returns_after_the_drag() { + for release in [HOLD_SECONDS / 2.0, 1.0] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + assert!(app.session.tool_busy(), "Scale is dragging"); + keys.frame(&mut app, release / 2.0, vec![], None); + keys.release_s(&mut app, release); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: Scale stays during the drag"); + keys.frame(&mut app, release + 0.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "let go at {release} s: still dragging"); + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + keys.frame(&mut app, release + 0.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "let go at {release} s: the Pen is back after the drag"); + } +} + +/// A click on the canvas between two frames while S is down uses Scale, so letting go of S before +/// the threshold returns to the Pen. +#[test] +fn a_click_between_frames_counts_as_using_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// A Cmd/Ctrl click while S is down goes to the selection tool lent to Scale, so it does not use +/// Scale: letting go of S before the threshold keeps Scale, whether or not a frame comes during +/// the click. +#[test] +fn a_click_lent_to_a_selection_tool_does_not_use_the_held_tool() { + for frame_during_click in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let mods = vectorcraft_tools::Mods { cmd: true, ..Default::default() }; + assert!(app.session.lent_tool_id(&mods).is_some(), "Scale lends a selection tool to a Cmd press"); + let view = app.view_info(); + let event = |kind| PointerEvent { mods, ..PointerEvent::new(kind, 150.0, 125.0) }; + crate::canvas::dispatch(&mut app, &event(PointerKind::Down), view); + if frame_during_click { + keys.frame(&mut app, HOLD_SECONDS / 4.0, vec![], None); + } + crate::canvas::dispatch(&mut app, &event(PointerKind::Up), view); + keys.release_s(&mut app, HOLD_SECONDS / 2.0); + assert_eq!(app.session.tool_id(), "scale", "frame during the click: {frame_during_click}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pan with the Hand tool while H is down uses the Hand: letting go of H quickly after the pan +/// returns to the Pen, and letting go of H during the pan returns once the pan ends. +#[test] +fn a_view_tool_drag_counts_as_using_the_held_tool() { + for let_go_during_pan in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.on_canvas(&mut app, 0.0, vec![]); + let at = app.canvas_rect.expect("the canvas was drawn").center(); + let to = at + egui::vec2(30.0, 20.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let key = |pressed| egui::Event::Key { key: Key::H, physical_key: Some(Key::H), pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.on_canvas(&mut app, 0.01, vec![egui::Event::PointerMoved(at), key(true), egui::Event::Text("h".into())]); + assert_eq!(app.session.tool_id(), "hand", "let go during the pan: {let_go_during_pan}"); + keys.on_canvas(&mut app, 0.02, vec![button(at, true)]); + keys.on_canvas(&mut app, 0.03, vec![egui::Event::PointerMoved(to)]); + assert!(crate::canvas::dragging(&keys.ctx), "the Hand pans"); + if let_go_during_pan { + keys.on_canvas(&mut app, 0.05, vec![key(false)]); + assert_eq!(app.session.tool_id(), "hand", "the Hand stays during the pan"); + keys.on_canvas(&mut app, 0.06, vec![button(to, false)]); + } else { + keys.on_canvas(&mut app, 0.05, vec![button(to, false)]); + keys.on_canvas(&mut app, 0.06, vec![key(false)]); + } + keys.on_canvas(&mut app, 0.07, vec![]); + assert_eq!(app.session.tool_id(), "pen", "let go during the pan: {let_go_during_pan}"); + assert!(app.held_tool.is_empty()); + } +} + +/// A pointer press in the frame that lets go of S: a press that reaches Scale on the canvas returns +/// to the Pen, and a press elsewhere (no canvas press follows) keeps Scale, also when the next frame +/// comes late. +#[test] +fn a_press_in_the_frame_that_lets_go_waits_for_the_canvas() { + for canvas in [true, false] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let at = egui::pos2(150.0, 125.0); + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![egui::Event::PointerMoved(at), press, release], None); + assert_eq!(app.session.tool_id(), "scale", "canvas {canvas}: no decision before the canvas"); + if canvas { + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), if canvas { "pen" } else { "scale" }, "canvas {canvas}"); + } +} + +/// S and then V pressed quickly, both let go in the frame of a canvas click with the Selection tool: +/// V was used, so Scale comes back, and S was a quick press, so Scale stays, also when the next +/// frame comes late. +#[test] +fn keys_let_go_together_are_timed_in_the_frame_that_lets_them_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let at = egui::pos2(150.0, 125.0); + let up = |key| egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + let press = egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![egui::Event::PointerMoved(at), press, up(Key::V), up(Key::S)], None); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.frame(&mut app, 2.0, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); + assert!(app.held_tool.is_empty()); +} + +/// With S held and then V held, a click with the Selection tool uses Selection only: V let go +/// quickly returns to Scale, and S let go quickly then keeps Scale. +#[test] +fn a_click_uses_only_the_tool_of_the_key_held_last() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + keys.press(&mut app, 0.02, Key::V, "v"); + let view = app.view_info(); + for kind in [PointerKind::Down, PointerKind::Up] { + crate::canvas::dispatch(&mut app, &PointerEvent::new(kind, 150.0, 125.0), view); + } + keys.release(&mut app, 0.05, Key::V); + assert_eq!(app.session.tool_id(), "scale", "V was used, so Scale is back"); + keys.release_s(&mut app, 0.1); + assert_eq!(app.session.tool_id(), "scale", "Scale was not used, so it stays"); +} + +/// Enter with S down opens the Scale dialog, and with V down the Move dialog; the key let go quickly +/// while the dialog is open returns to the Pen once the dialog closes. +#[test] +fn a_dialog_opened_during_the_hold_counts_as_using_the_tool() { + for (key, letter, tool, dialog) in [(Key::S, "s", "scale", "scale"), (Key::V, "v", "selection", "move")] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press(&mut app, 0.0, key, letter); + let enter = + egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.05, vec![enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialog), "Enter opens the {dialog} dialog"); + keys.release(&mut app, 0.1, key); + assert_eq!(app.session.tool_id(), tool, "{tool} stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "pen", "after the {dialog} dialog"); + } +} + +/// Enter pressed in the frame that lets go of S after the threshold opens the Scale dialog, and +/// Scale stays until the dialog closes. +#[test] +fn enter_in_the_frame_that_lets_go_reaches_the_held_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let enter = egui::Event::Key { key: Key::Enter, physical_key: Some(Key::Enter), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 1.0, vec![release, enter], None); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("scale"), "Enter opens the Scale dialog"); + keys.frame(&mut app, 1.1, vec![], None); + assert_eq!(app.session.tool_id(), "scale", "Scale stays while its dialog is open"); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 1.2, vec![], None); + assert_eq!(app.session.tool_id(), "pen"); +} + +/// Another dialog (Preferences) opened while S is down and S let go quickly while it is open: Scale +/// was not used, so it stays once the dialog closes. +#[test] +fn another_dialog_opened_during_the_hold_does_not_use_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + crate::prefs_dialog::open(&mut app, None); + keys.frame(&mut app, 0.05, vec![], None); + keys.release_s(&mut app, 0.1); + crate::dialogs::cancel(&mut app); + keys.frame(&mut app, 0.15, vec![], None); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// S typed and held in a text field types its letter and chooses no tool. +#[test] +fn a_tool_key_held_in_a_text_field_types_and_keeps_the_tool() { + let mut app = pen_app(); + let keys = Keyboard::new(); + let mut text = String::new(); + keys.frame(&mut app, 0.0, vec![], Some(&mut text)); + let press = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let release = egui::Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers: egui::Modifiers::NONE }; + keys.frame(&mut app, 0.1, vec![press.clone(), egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 0.6, vec![press, egui::Event::Text("s".into())], Some(&mut text)); + keys.frame(&mut app, 1.0, vec![release], Some(&mut text)); + assert_eq!(text, "ss"); + assert_eq!(app.session.tool_id(), "pen"); + assert!(app.held_tool.is_empty()); +} + +/// With General › Hold a Tool Shortcut to Use the Tool Temporarily off, a held S chooses Scale for +/// good, as a quick press does. +#[test] +fn with_the_preference_off_a_held_tool_key_chooses_the_tool_for_good() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + hold_s(&mut app, &keys, &[0.1, 0.2, 0.3, 0.4], 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// With the preference off, each repeat of a held S chooses Scale again, as before: a flyout opened +/// in between closes. +#[test] +fn with_the_preference_off_each_repeat_chooses_the_tool_again() { + let mut app = pen_app(); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, None, "the repeat chose Scale again"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// A hold whose viewport stopped drawing ends when another viewport has the keyboard or the +/// preference is turned off, and stays while the other viewport does not have the keyboard. +#[test] +fn a_hold_of_another_viewport_ends_when_this_one_has_the_keyboard() { + let other = egui::ViewportId::from_hash_of("held-tool-other"); + let hold = || HeldTool { key: Key::S, viewport: other, tool: "scale", previous: "pen", since: 0.0, used: false, released: None }; + let mut app = pen_app(); + app.select_tool("scale"); + let keys = Keyboard::new(); + app.held_tool.push(hold()); + keys.frame_with_focus(&mut app, 0.5, vec![], None, false); + assert_eq!(app.held_tool.len(), 1, "a viewport without the keyboard leaves the hold alone"); + keys.frame(&mut app, 1.0, vec![], None); + assert!(app.held_tool.is_empty(), "a viewport with the keyboard ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); + app.held_tool.push(hold()); + app.run("prefs.set", json!({"key": "holdToolKeys", "value": false})).unwrap(); + keys.frame_with_focus(&mut app, 1.5, vec![], None, false); + assert!(app.held_tool.is_empty(), "turning the preference off ends the hold"); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// Scale chosen again on the toolbar while S is held stays chosen when S is let go. +#[test] +fn the_held_tool_chosen_again_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("scale"); + // A tool chosen closes an open flyout: the repeats of S, still down, choose nothing. + app.ui.flyout = Some(0); + keys.press_s(&mut app, 0.3); + assert_eq!(app.ui.flyout, Some(0), "a repeat chooses no tool"); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "scale"); +} + +/// The window losing the keyboard while S is held, also during a drag, ends the hold: Scale stays. +#[test] +fn losing_the_keyboard_ends_the_hold() { + for drag in [false, true] { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + let view = app.view_info(); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap(); + app.session.pointer(&PointerEvent::new(PointerKind::Drag, 260.0, 190.0), view).unwrap(); + } + keys.frame_with_focus(&mut app, 0.3, vec![egui::Event::WindowFocused(false)], None, false); + if drag { + app.session.pointer(&PointerEvent::new(PointerKind::Up, 260.0, 190.0), view).unwrap(); + } + keys.frame(&mut app, 0.5, vec![egui::Event::WindowFocused(true)], None); + keys.release_s(&mut app, 0.6); + assert_eq!(app.session.tool_id(), "scale", "drag {drag}: Scale stays"); + } +} + +/// Another tool chosen while S is held stays chosen when S is let go. +#[test] +fn a_tool_chosen_during_the_hold_stays_when_the_key_is_let_go() { + let mut app = pen_app(); + let keys = Keyboard::new(); + keys.press_s(&mut app, 0.0); + app.select_tool("rectangle"); + keys.frame(&mut app, 0.3, vec![], None); + keys.release_s(&mut app, 0.5); + assert_eq!(app.session.tool_id(), "rectangle"); +} diff --git a/crates/ui-egui/src/shortcuts/tests.rs b/crates/ui-egui/src/shortcuts/tests.rs index 5fbffb533..b64dd4f7a 100644 --- a/crates/ui-egui/src/shortcuts/tests.rs +++ b/crates/ui-egui/src/shortcuts/tests.rs @@ -1288,6 +1288,31 @@ fn holding_the_enter_that_confirms_a_dialog_types_no_line_break() { assert_ne!(text_and_layers(&mut app, text).0, before, "a new press types one"); } +/// A held Cmd+Enter ends text editing once. Its repeats add no line break and do not open the Move +/// dialog for the type it leaves selected; a new press does, as after Escape. +#[test] +fn holding_cmd_enter_ends_text_editing_once() { + let mut app = app_with_a_selected_rectangle(); + let text = app.session.execute("text.create", &json!({"x": 20, "y": 250, "text": "Hi"})).unwrap()["id"].as_u64().unwrap(); + app.select_tool("type"); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(app.session.tool_wants_text()); + let held = HeldKey::new(Key::Enter, Modifiers::COMMAND); + held.frame(&mut app, vec![]); + let before = text_and_layers(&mut app, text); + held.press(&mut app); + assert!(!app.session.tool_wants_text(), "the press ends editing"); + assert_eq!(app.session.tool_id(), "selection"); + for repeat in 0..4 { + held.press(&mut app); + assert_eq!(text_and_layers(&mut app, text), before, "repeat {repeat}: no line break"); + assert_eq!(open_dialog(&app), None, "repeat {repeat}: no dialog"); + } + held.release(&mut app); + held.press(&mut app); + assert_eq!(open_dialog(&app).as_deref(), Some("move"), "a new press opens Move"); +} + /// A release that never reaches the main window costs the next press: egui takes that press for a /// repeat, and its release clears the key, so the press after it acts again. #[test] diff --git a/crates/ui-egui/src/shortcuts/typing.rs b/crates/ui-egui/src/shortcuts/typing.rs index cdd8c16f8..a3127adde 100644 --- a/crates/ui-egui/src/shortcuts/typing.rs +++ b/crates/ui-egui/src/shortcuts/typing.rs @@ -52,7 +52,7 @@ enum Seq { InterruptNavigation, InterruptCommand, ResetCompleted(egui::ImeResetId), - /// Enter, which types a line break. + /// Enter, which types a line break. Cmd+Enter (Ctrl+Enter on Windows and Linux) ends editing. Enter(Mods), /// An editing key with modifiers, an arrow, Home, End, Tab or a clipboard event. Edit(TextInput), @@ -74,7 +74,8 @@ pub(super) fn type_text(app: &mut VectorcraftApp, ctx: &egui::Context) -> bool { // characters in one frame interleaves correctly. Taken as two passes (all text, then the keys) // the deletes land after the inserts and the diacritics are lost. Enter, the other editing keys // and the clipboard events join them: ← or a paste lands between the texts around it, and each - // Enter types its own line break. Each handled event is removed from the input. + // Enter types its own line break (or, as Cmd+Enter, ends editing there). Each handled event is + // removed from the input. let seq = type_sequence(ctx); let viewport = ctx.viewport_id(); let mut old_composition_retired = false; @@ -177,7 +178,7 @@ fn type_sequence(ctx: &egui::Context) -> Vec { let tk = if key == Key::Backspace { ToolKey::Backspace } else { ToolKey::Delete }; seq.push(Seq::Erase(tk, Mods { shift: m.shift, ..Mods::default() })); } - // A command bound to the chord (Cmd+Enter) runs instead of the line break, in the + // A command bound to the chord (Cmd+Enter) runs instead of ending editing, in the // shortcuts after this. egui::Event::Key { key: Key::Enter, pressed, modifiers: m, .. } if !chord_runs_a_command(Key::Enter, m, true) => { if pressed { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 14cd10d28..85f87b723 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -204,6 +204,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ (crate::panels::links::ID, "Links", "link"), (crate::panels::asset_export::ID, "Asset Export", "share-2"), (crate::panels::css_properties::ID, "CSS Properties", "globe"), + (crate::panels::three_d::ID, "3D and Materials", "dc-cube"), (crate::panels::variables::ID, "Variables", "chart-column"), (crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"), (crate::addons::color::ID, "Color Effects", "sun"), @@ -370,6 +371,9 @@ pub struct UiState { #[serde(skip)] pub transient_focus: bool, pub control_bar: bool, + /// The Control bar is docked to the bottom of the window instead of the top + /// (`window.control.dock`). + pub control_bar_bottom: bool, pub toolbar: bool, pub toolbar_double: bool, /// Advanced toolbar (every tool group) instead of the categorized Basic toolbar. @@ -961,6 +965,7 @@ impl Default for UiState { transient_panel: None, transient_focus: false, control_bar: false, + control_bar_bottom: false, toolbar: true, toolbar_double: false, toolbar_advanced: false, diff --git a/crates/ui-egui/src/tests/numfields.rs b/crates/ui-egui/src/tests/numfields.rs index d4dc7775e..8d475a917 100644 --- a/crates/ui-egui/src/tests/numfields.rs +++ b/crates/ui-egui/src/tests/numfields.rs @@ -418,6 +418,181 @@ fn key(key: Key, modifiers: Modifiers) -> Event { Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } } +/// What [`preset_row`]'s fields committed or picked in a frame: the two numbers, the text and the +/// pick of the field with an Auto entry. +type PresetPicks = (Option, Option, Option, Option); + +const NO_PICK: PresetPicks = (None, None, None, None); + +thread_local! { + /// Whether the last [`preset_row`] frame left an Enter press for a dialog drawn after it. + static ENTER_LEFT: Cell = const { Cell::new(false) }; +} + +/// One frame in `ctx` with `events` of a row of two numeric fields with a spinner and presets +/// (Stroke Weight, and a font size that is none of its presets), a text field with presets (Export +/// for Screens' scale) and a numeric field whose list starts with Auto, which is on (Leading). +fn preset_row(ctx: &egui::Context, time: &Cell, events: Vec) -> PresetPicks { + time.set(time.get() + 0.05); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0))); + let mut got = NO_PICK; + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| { + ui.horizontal(|ui| { + got.0 = widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[1.0, 2.0, 4.0]); + got.1 = widgets::spin_field(ui, "size", Some(13.0), Unit::Points, 100.0, 1.0, 0.0, &[9.0, 12.0]); + got.2 = widgets::text_presets(ui, "scale", "1x", &["1x", "2x"], 100.0); + got.3 = widgets::spin_field_auto(ui, "leading", Some(14.0), Some(true), Unit::Points, 100.0, 1.0, 0.0, &[12.0, 14.0]); + }); + ENTER_LEFT.set(crate::shortcuts::pressed_anew(ui.ctx(), Key::Enter)); + }); + out.textures_delta.clear(); + got +} + +/// Tab goes from one field to the next in one press: it does not stop on a field's spinner or on +/// the chevron that opens its presets, and Shift+Tab goes back the same way. +#[test] +fn tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + preset_row(&ctx, &time, vec![]); + preset_row(&ctx, &time, vec![]); + let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 8, |events| { + preset_row(&ctx, &time, events); + }); + let widths: Vec = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect(); + assert_eq!(widths.len(), 8, "{stops:?}"); + assert!(widths.iter().all(|w| *w > 40.0), "only the fields are Tab stops, not the spinners or chevrons: {widths:?}"); + assert_eq!(stops[..4], stops[4..], "four fields, then back to the first: {widths:?}"); + let mut back = vec![]; + for _ in 0..2 { + preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::SHIFT)]); + preset_row(&ctx, &time, vec![]); + back.extend(ctx.memory(|m| m.focused())); + } + assert_eq!(back, [stops[2], stops[1]], "Shift+Tab goes back field by field"); +} + +/// The Transparency row's opacity slider chevron is not a Tab stop either: Tab goes from the +/// Opacity field straight to the next field. +#[test] +fn tab_goes_from_the_opacity_field_past_its_slider_chevron() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + let frame = |events: Vec| { + time.set(time.get() + 0.05); + let screen_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(600.0, 300.0))); + let mut out = ctx.run_ui(egui::RawInput { events, time: Some(time.get()), screen_rect, focused: true, ..Default::default() }, |ui| { + ui.horizontal(|ui| { + widgets::opacity_blend(ui, "transparency", Some(0.5), None, true); + widgets::spin_field(ui, "weight", Some(2.0), Unit::Points, 100.0, 1.0, 0.0, &[]); + }); + }); + out.textures_delta.clear(); + }; + frame(vec![]); + frame(vec![]); + let stops = crate::widgets::accessibility_tests::tab_stops(&ctx, 6, frame); + let widths: Vec = stops.iter().filter_map(|id| ctx.read_response(*id)).map(|r| r.rect.width()).collect(); + assert_eq!(widths.len(), 6, "{stops:?}"); + assert!(widths.iter().all(|w| *w > 20.0), "the 18 pt slider chevron is not a Tab stop: {widths:?}"); + assert_eq!(stops[..3], stops[3..], "blend mode, Opacity and the next field, then back: {widths:?}"); +} + +/// Gives the keyboard focus to the field that `tabs` presses of Tab reach in [`preset_row`], with +/// every list closed, and returns that field. +fn focus_preset_field(ctx: &egui::Context, time: &Cell, tabs: usize) -> Option { + egui::Popup::close_all(ctx); + ctx.memory_mut(|m| m.surrender_focus(m.focused().unwrap_or(egui::Id::NULL))); + preset_row(ctx, time, vec![]); + for _ in 0..tabs { + preset_row(ctx, time, vec![key(Key::Tab, Modifiers::NONE)]); + } + preset_row(ctx, time, vec![]); + ctx.memory(|m| m.focused()) +} + +/// The presets stay in reach of the keyboard: Alt+↓ in a field opens its preset list with the +/// keyboard focus on the chosen preset (the first entry when none is chosen, Auto when it is on), +/// Tab goes on to the next one and Enter picks it, without reaching a dialog's OK. The field then +/// has the keyboard focus again. +#[test] +fn alt_down_opens_a_fields_presets_for_the_keyboard() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + let alt_down = || key(Key::ArrowDown, Modifiers::ALT); + // (Tabs to the field, the pick after Tab and Enter in its list.) + let cases = [ + (1, (Some(4.0), None, None, None)), + (2, (None, Some(12.0), None, None)), + (3, (None, None, Some("2x".to_string()), None)), + (4, (None, None, None, Some(widgets::SpinPick::Value(12.0)))), + ]; + for (tabs, picked) in cases { + let field = focus_preset_field(&ctx, &time, tabs); + assert!(field.is_some(), "{tabs}"); + assert_eq!(preset_row(&ctx, &time, vec![alt_down()]), NO_PICK, "{tabs}"); + preset_row(&ctx, &time, vec![]); + let entry = ctx.memory(|m| m.focused()); + assert!(entry.is_some() && entry != field, "{tabs}: the list takes the focus"); + let layer = ctx.read_response(entry.unwrap_or(egui::Id::NULL)).map(|r| r.layer_id.order); + assert_eq!(layer, Some(egui::Order::Foreground), "{tabs}: on an entry of the list"); + preset_row(&ctx, &time, vec![key(Key::Tab, Modifiers::NONE)]); + preset_row(&ctx, &time, vec![]); + let mut got = preset_row(&ctx, &time, vec![enter()]); + assert!(!ENTER_LEFT.get(), "{tabs}: the list takes the Enter, so a dialog's OK does not"); + if got == NO_PICK { + got = preset_row(&ctx, &time, vec![]); + } + assert_eq!(got, picked, "{tabs}: Enter picks the next preset"); + preset_row(&ctx, &time, vec![]); + preset_row(&ctx, &time, vec![]); + assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed"); + assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again"); + } +} + +/// Escape closes a preset list that Alt+↓ opened without picking anything, and the field has the +/// keyboard focus again. +#[test] +fn escape_closes_a_fields_presets_and_returns_to_the_field() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + for tabs in [1, 3] { + let field = focus_preset_field(&ctx, &time, tabs); + preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::ALT)]); + preset_row(&ctx, &time, vec![]); + assert!(egui::Popup::is_any_open(&ctx), "{tabs}: Alt+↓ opened the list"); + let mut picks = vec![preset_row(&ctx, &time, vec![key(Key::Escape, Modifiers::NONE)])]; + picks.push(preset_row(&ctx, &time, vec![])); + picks.push(preset_row(&ctx, &time, vec![])); + assert!(picks.iter().all(|p| *p == NO_PICK), "{tabs}: nothing picked: {picks:?}"); + assert!(!egui::Popup::is_any_open(&ctx), "{tabs}: the list closed"); + assert_eq!(ctx.memory(|m| m.focused()), field, "{tabs}: the field has the keyboard focus again"); + } +} + +/// Only Alt+↓ with no other modifier opens the presets: Shift+Alt+↓ in a field with presets +/// steps it down by ten, as before, and leaves the list closed. +#[test] +fn shift_alt_down_still_steps_a_field_with_presets() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let time = Cell::new(0.0); + let field = focus_preset_field(&ctx, &time, 2); + let mut got = preset_row(&ctx, &time, vec![key(Key::ArrowDown, Modifiers::SHIFT | Modifiers::ALT)]); + if got == NO_PICK { + got = preset_row(&ctx, &time, vec![]); + } + assert_eq!(got.1, Some(3.0), "13 pt steps down by ten"); + assert!(!egui::Popup::is_any_open(&ctx), "the list stays closed"); + assert_eq!(ctx.memory(|m| m.focused()), field); +} + #[test] fn math_before_the_unit_suffix_evaluates_in_every_unit() { assert_eq!(Unit::Pixels.parse("1080/2 px"), Some(540.0)); diff --git a/crates/ui-egui/src/tests/pathtype.rs b/crates/ui-egui/src/tests/pathtype.rs index 46f5eff67..4a14c1e0f 100644 --- a/crates/ui-egui/src/tests/pathtype.rs +++ b/crates/ui-egui/src/tests/pathtype.rs @@ -30,13 +30,18 @@ fn open_options(app: &mut VectorcraftApp) { } /// Two headless canvas frames → the vertices of the second one's tessellated mesh, which the GPU -/// takes in one buffer. +/// takes in one buffer, without the art's raster: the render worker's frame can land in either +/// frame of a comparison. fn canvas_vertices(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize { let raw = || egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }; ctx.run_ui(raw(), |ui| crate::canvas::show(app, ui)).textures_delta.clear(); let mut out = ctx.run_ui(raw(), |ui| crate::canvas::show(app, ui)); out.textures_delta.clear(); - let mesh = |p: &egui::ClippedPrimitive| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { m.vertices.len() } else { 0 }; + let art = app.canvas.texture.as_ref().map(|t| t.id()); + let mesh = |p: &egui::ClippedPrimitive| match &p.primitive { + egui::epaint::Primitive::Mesh(m) if Some(m.texture_id) != art => m.vertices.len(), + _ => 0, + }; ctx.tessellate(out.shapes, out.pixels_per_point).iter().map(mesh).sum() } diff --git a/crates/ui-egui/src/tests/picks.rs b/crates/ui-egui/src/tests/picks.rs index 326b2b2c0..fdd0798a8 100644 --- a/crates/ui-egui/src/tests/picks.rs +++ b/crates/ui-egui/src/tests/picks.rs @@ -463,6 +463,29 @@ fn keyboard_shortcuts_import_opens_a_text_set_over_the_dialog() { let _ = std::fs::remove_dir_all(dir); } +/// `shortcuts.export` and `shortcuts.import` given a `path` that is not a string open no file +/// dialog, read and write nothing, and give an error naming `path`; a null `path` still asks. +#[test] +fn keyboard_shortcuts_commands_reject_a_path_that_is_not_a_string() { + let (mut app, shown, written) = app(); + let read: Written = Default::default(); + let r = read.clone(); + app.services.read = Some(Box::new(move |path: &str| { + r.borrow_mut().push(path.to_string()); + Ok(SVG.to_vec()) + })); + for path in [json!(5), json!(true), json!({"file": "/keys/a.json"}), json!(["/keys/a.json"])] { + for id in ["shortcuts.export", "shortcuts.import"] { + assert_eq!(app.run(id, json!({ "path": path })), Err("`path` must be a string".to_string()), "{id} {path}"); + assert!(shown.requests().is_empty(), "{id} {path}: {:?}", shown.requests()); + } + } + assert!(written.borrow().is_empty() && read.borrow().is_empty()); + assert!(app.ui.shortcut_overrides.is_empty()); + assert_eq!(app.run("shortcuts.export", json!({"path": null})), Err("cancelled".to_string())); + assert!(matches!(shown.requests().as_slice(), [PickRequest::Save(_)])); +} + /// A dialog's folder button answered after the dialog closed fills nothing, and the status says /// why. #[test] diff --git a/crates/ui-egui/src/tests/plugin_runs.rs b/crates/ui-egui/src/tests/plugin_runs.rs index a9b299eda..194ec133d 100644 --- a/crates/ui-egui/src/tests/plugin_runs.rs +++ b/crates/ui-egui/src/tests/plugin_runs.rs @@ -173,6 +173,46 @@ fn continue_in_background_hides_the_progress_window() { assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); } +/// A heavy filter of plug-in API level 2 that reports its progress gets a bar in its progress window, +/// with the share it reported, clamped; Cancel makes its `vc_cancelled` return 1 and it stops, +/// changing nothing. +#[test] +fn a_filter_that_reports_progress_shows_a_bar_and_sees_cancel() { + let mut app = app(); + let id = "test.ui.heavy.progress"; + let manifest = + format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","budget":"heavy","api":2,"permissions":["doc.read","doc.write","progress"]}}"#); + let first = "(call $vc_progress (i64.const 5000) (i64.const 1000)) (call $vc_progress (i64.const 250) (i64.const 1000)) + (block $stop (loop $l (br_if $stop (call $vc_cancelled)) (br $l)))"; + let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::reporting(&manifest, first, r#"{"objects":[]}"#)).unwrap()); + app.run("plugin.install", json!({"dataBase64": data, "allowHeavy": true, "grant": ["doc.read", "doc.write", "progress"]})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + app.run("plugin.dialog", json!({"id": id})).unwrap(); + // Waits for the run's thread to report the share (a handshake: the thread never stops before + // it is cancelled, and a run that ended leaves the loop). + loop { + plugin_runs::poll(&mut app); + match app.plugin_runs.running.first() { + Some(r) if r.progress.counts() != (250, 1000) => std::thread::yield_now(), + _ => break, + } + } + assert_eq!(app.plugin_runs.running.first().map(|r| r.progress.counts()), Some((250, 1000)), "the second report, after the first was clamped"); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + progress(&mut app, &ctx, 0.0); + progress(&mut app, &ctx, 1.0); + let shown = progress(&mut app, &ctx, 1.1); + assert!(shown.contains("The plug-in is computing…") && shown.contains("25 %"), "{shown}"); + assert_eq!(app.run("plugin.cancelRuns", json!({})).unwrap()["cancelled"], 1); + while !app.plugin_runs.running.is_empty() { + plugin_runs::poll(&mut app); + std::thread::yield_now(); + } + assert_eq!(app.session.doc().unwrap().doc, doc); + assert_eq!(app.ui.status, vectorcraft_engine::EngineError::Cancelled.to_string()); +} + /// `CRAFT_UI_HEAVY_FILTER_WASM=` writes a heavy test filter to install in the app /// (`order-paths-test-filter.wasm`): it computes until it is cancelled or its budget runs out, so /// its progress window stays up for a picture. diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index dbb32769a..d5e10d8d1 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -183,3 +183,19 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "replace: false is kept"); app.run("plugin.remove", json!({"id": id})).unwrap(); } + +/// A plug-in of API level 2 that lists `progress` names it in plain words when it asks. +#[test] +fn installing_a_plugin_that_reports_progress_says_so() { + let mut app = app(); + let id = "test.ui.permissions.progress"; + let manifest = format!(r#"{{"id":"{id}","name":"Count","kind":"filter","api":2,"permissions":["doc.read","progress"]}}"#); + let bytes = wat::parse_str(tpl::reporting(&manifest, "(call $vc_progress (i64.const 1) (i64.const 2))", r#"{"objects":[]}"#)).unwrap(); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND); + let detail = app.ui.dialog.as_ref().unwrap().str("detail"); + assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}"); + dialogs::confirm(&mut app).unwrap(); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2)); + app.run("plugin.remove", json!({"id": id})).unwrap(); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 53281dc69..55fb3fbc6 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -566,16 +566,29 @@ pub(crate) fn craft_font_name(f: &vectorcraft_text::CraftFont) -> String { /// The Japanese, Georgian and Devanagari craft-fonts faces (when built with `CRAFT_FONTS_DIR`) as fallbacks at /// the end of every family, after the app's own fonts and before the installed fonts `ui_fonts` /// adds: BIZ UDPGothic first (bold first in the semibold family), then the Mincho faces, then Noto -/// Sans Georgian. +/// Sans Georgian. Each face sits on the baseline of the first face of the family it is in (see +/// `ui_fonts::baseline_copies`). fn add_craft_fonts(fonts: &mut FontDefinitions) { + // The name of each face's copy in each family. + let mut copies: std::collections::HashMap<(FontFamily, String), String> = std::collections::HashMap::new(); for f in vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.is_japanese() || f.is_georgian() || f.scripts.contains(&"Deva")) { - fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes))); + let name = craft_font_name(f); + for (copy, data, families) in crate::ui_fonts::baseline_copies(fonts, &name, &FontData::from_static(f.bytes)) { + for family in families { + copies.insert((family, name.clone()), copy.clone()); + } + fonts.font_data.insert(copy, Arc::new(data)); + } } for (family, stack) in fonts.families.iter_mut() { let bold = *family == FontFamily::Name(FONT_UI_SEMIBOLD.into()); - stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(craft_font_name)); - stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(craft_font_name)); - stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(craft_font_name)); + let copy = |f: &vectorcraft_text::CraftFont| { + let name = craft_font_name(f); + copies.get(&(family.clone(), name.clone())).cloned().unwrap_or(name) + }; + stack.extend(vectorcraft_text::craft_fonts::japanese_ui_fonts(bold).into_iter().map(copy)); + stack.extend(vectorcraft_text::craft_fonts::georgian_ui_fonts().into_iter().map(copy)); + stack.extend(vectorcraft_text::CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Deva")).map(copy)); } } diff --git a/crates/ui-egui/src/ui_fonts.rs b/crates/ui-egui/src/ui_fonts.rs index bcb6c909e..ac3f260e9 100644 --- a/crates/ui-egui/src/ui_fonts.rs +++ b/crates/ui-egui/src/ui_fonts.rs @@ -4,16 +4,17 @@ //! which `theme::install_fonts` adds after the UI fonts, so Japanese never gets here; nothing else //! is bundled for them: the first time a frame paints a character the UI fonts //! don't have, an installed font covering it is looked for (in the background on native) and added -//! to egui's families as their last fallback, from the next frame on. Text the UI fonts cover costs -//! no font loading or memory. On the web, which has no system fonts, such characters stay -//! missing-glyph boxes (Japanese too, unless built with craft-fonts). +//! to egui's families as their last fallback, on the baseline of each family's first face, from +//! the next frame on. Text the UI fonts cover costs no font loading or memory. On the web, which +//! has no system fonts, such characters stay missing-glyph boxes (Japanese too, unless built with +//! craft-fonts). use std::collections::HashSet; use std::sync::Arc; use egui::epaint::Shape; use egui::epaint::text::{FontInsert, FontPriority, InsertFontFamily}; -use egui::{FontData, FontFamily, FontId, LayerId}; +use egui::{FontData, FontDefinitions, FontFamily, FontId, FontTweak, LayerId}; use vectorcraft_text::{FontDb, FontFace}; #[derive(Default)] @@ -109,9 +110,9 @@ impl UiFonts { missing } - /// Add `faces` to every egui family, after the fonts it has. + /// Add `faces` to every egui family, after the fonts it has, on the baseline of the family's + /// first face. fn install(&mut self, ctx: &egui::Context, faces: &[Arc]) { - let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); for face in faces { let name = format!("{} {} #{}", face.family, face.style, face.face_index()); if !self.added.insert(name.clone()) { @@ -121,8 +122,10 @@ impl UiFonts { // face it loads for the session) instead of copied: one handle is leaked per font added. let face: &'static FontFace = Box::leak(Box::new(face.clone())); let data = FontData { index: face.face_index(), ..FontData::from_static(face.file_data()) }; - let families = families.iter().map(|family| InsertFontFamily { family: family.clone(), priority: FontPriority::Lowest }).collect(); - ctx.add_font(FontInsert { name, data, families }); + for (name, data, families) in ctx.fonts(|f| baseline_copies(f.definitions(), &name, &data)) { + let families = families.into_iter().map(|family| InsertFontFamily { family, priority: FontPriority::Lowest }).collect(); + ctx.add_font(FontInsert { name, data, families }); + } } if !faces.is_empty() { ctx.request_repaint(); @@ -138,6 +141,53 @@ impl UiFonts { } } +/// The copies of the fallback face `data` that `defs`' families take, one for each baseline they +/// need: each copy is `data` with the `y_offset_factor` that puts its glyphs on the baseline of the +/// first face of the families listed with it. Families whose first faces need the same shift share +/// a copy. The first copy (the Proportional family's, when `defs` has it) is named `name`; each +/// other copy adds the name of its families' first face. +pub(crate) fn baseline_copies(defs: &FontDefinitions, name: &str, data: &FontData) -> Vec<(String, FontData, Vec)> { + let mut copies: Vec<(String, FontData, Vec)> = vec![]; + for (family, stack) in &defs.families { + let head = stack.first().and_then(|head| Some((head.as_str(), defs.font_data.get(head)?))); + let shift = head.and_then(|(_, data)| row_metrics(data)).map_or(0.0, |row| baseline_shift(row, (*data.font).as_ref(), data.index)); + if let Some((_, _, families)) = copies.iter_mut().find(|(_, copy, _)| copy.tweak.y_offset_factor.to_bits() == shift.to_bits()) { + families.push(family.clone()); + continue; + } + let copy_name = if copies.is_empty() { name.to_owned() } else { format!("{name} / {}", head.map_or("", |(head, _)| head)) }; + let tweak = FontTweak { y_offset_factor: shift, ..data.tweak.clone() }; + copies.push((copy_name, FontData { tweak, ..data.clone() }, vec![family.clone()])); + } + copies +} + +/// The ascent, row height and vertical offset of a family's first face `data`, in ems of the font +/// size, with that face's `FontTweak` scale applied, as egui lays out its rows. `None` when the +/// face can't be read. +fn row_metrics(data: &FontData) -> Option<(f32, f32, f32)> { + let (ascent, height) = em_metrics((*data.font).as_ref(), data.index)?; + let scale = data.tweak.scale; + Some((ascent * scale, height * scale, data.tweak.y_offset_factor * scale)) +} + +/// The ascent and row height (ascent, descent and line gap) of the face at `index` of `data`, in ems. +fn em_metrics(data: &[u8], index: u32) -> Option<(f32, f32)> { + let (upem, ascent, descent, gap) = vectorcraft_text::line_metrics(data, index)?; + Some((ascent / upem, (ascent - descent + gap) / upem)) +} + +/// The `y_offset_factor` that puts the glyphs of the face at `index` of `face` on the baseline of +/// the row's first face, whose ascent, row height and offset in ems are `row`. egui draws a +/// fallback glyph's baseline at its face's ascent plus half the difference between the two row +/// heights, so a face with a taller ascent or descent sits off the baseline by the shift computed +/// here, in ems of the font size, limited to half an em either way. +fn baseline_shift(row: (f32, f32, f32), face: &[u8], index: u32) -> f32 { + let (row_ascent, row_height, row_offset) = row; + let shift = em_metrics(face, index).map_or(0.0, |(ascent, height)| row_ascent + row_offset - (ascent + 0.5 * (row_height - height))); + if shift.is_finite() { shift.clamp(-0.5, 0.5) } else { 0.0 } +} + /// Call `f` with the text of `shape` and the shapes it holds. fn painted_text(shape: &Shape, f: &mut impl FnMut(&str)) { match shape { @@ -242,6 +292,94 @@ mod tests { assert!(has_glyph(&ctx, 'L')); } + /// An installed face with a taller ascent and descent than the UI faces (a CJK system face's + /// proportions) draws on the baseline of each family's first face, the monospace families' + /// included: `H` and the test face's 编 (the same `H` outline, which stands on the baseline) end + /// at the same height. + #[test] + fn fallback_faces_sit_on_the_ui_baseline() { + use vectorcraft_text::test_fonts::{TALL_IDEOGRAPH_FAMILY, tall_ideograph_font}; + let db = FontDb::with_font_dirs(vec![]); + db.add_font(tall_ideograph_font().expect("the test face builds")); + let face = db.face(TALL_IDEOGRAPH_FAMILY, "Regular").expect("the test face loads"); + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + crate::theme::install_fonts(&ctx); + let mut fonts = UiFonts::default(); + frame(&ctx, &mut fonts, "H"); + assert!(!has_glyph(&ctx, '编'), "no UI face has 编"); + fonts.install(&ctx, &[face]); + frame(&ctx, &mut fonts, "H"); + assert!(has_glyph(&ctx, '编'), "the test face draws 编"); + let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y; + let families: Vec = ctx.fonts(|f| f.definitions().families.keys().cloned().collect()); + for family in families { + for size in [13.0_f32, 24.0, 40.0] { + let galley = ctx.fonts_mut(|f| f.layout_no_wrap("H编".into(), FontId::new(size, family.clone()), egui::Color32::WHITE)); + let row = &galley.rows[0].glyphs; + assert_eq!(row.len(), 2, "H编 lays out as two glyphs"); + // Half a point: one device pixel at 2 pixels per point, for each glyph's rounding. + let drift = bottom(&row[1]) - bottom(&row[0]); + assert!(drift.abs() <= 0.5, "{family:?} at {size} pt: 编 ends {drift:.2} pt below H (negative: above)"); + } + } + } + + /// The bundled craft-fonts faces draw on the baseline of each family's first face: a glyph of + /// each ends as far below `H`'s baseline as it does below its own face's baseline when that face + /// heads a family. + #[test] + fn craft_fonts_sit_on_the_baseline_of_each_family() { + use vectorcraft_text::CraftFont; + use vectorcraft_text::craft_fonts::{georgian_ui_fonts, japanese_ui_fonts}; + // The face each family draws a script in, with a character of it: (regular face, semibold + // family's face, character). + let first = |faces: Vec<&'static CraftFont>| faces.first().copied(); + let deva = vectorcraft_text::CRAFT_FONTS.iter().find(|f| f.scripts.contains(&"Deva")); + let probes: Vec<(&CraftFont, &CraftFont, char)> = [ + first(japanese_ui_fonts(false)).zip(first(japanese_ui_fonts(true))).map(|(regular, bold)| (regular, bold, '編')), + first(georgian_ui_fonts()).map(|f| (f, f, 'ა')), + deva.map(|f| (f, f, 'क')), + ] + .into_iter() + .flatten() + .collect(); + if probes.is_empty() { + eprintln!("skipped: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout to run it)"); + return; + } + let mut defs = crate::theme::interface_fonts(); + let families: Vec = defs.families.keys().cloned().collect(); + let probe_name = |f: &CraftFont| format!("probe {}", crate::theme::craft_font_name(f)); + let probe = |f: &CraftFont| FontFamily::Name(probe_name(f).into()); + for &(regular, bold, _) in &probes { + for f in [regular, bold] { + defs.font_data.insert(probe_name(f), Arc::new(FontData::from_static(f.bytes))); + defs.families.insert(probe(f), vec![probe_name(f)]); + } + } + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + ctx.set_fonts(defs); + frame(&ctx, &mut UiFonts::default(), "H"); + let bottom = |g: &egui::epaint::text::Glyph| g.pos.y + g.uv_rect.offset.y + g.uv_rect.size.y; + let glyphs = |text: &str, font: FontId| ctx.fonts_mut(|f| f.layout_no_wrap(text.into(), font, egui::Color32::WHITE)).rows[0].glyphs.clone(); + for family in families { + let semibold = family == FontFamily::Name(crate::theme::FONT_UI_SEMIBOLD.into()); + for &(regular, bold, c) in &probes { + let face = if semibold { bold } else { regular }; + for size in [13.0_f32, 24.0, 40.0] { + let own = glyphs(&c.to_string(), FontId::new(size, probe(face))); + let row = glyphs(&format!("H{c}"), FontId::new(size, family.clone())); + assert_eq!((own.len(), row.len()), (1, 2), "{c} lays out as one glyph"); + // Half a point: one device pixel at 2 pixels per point, for each glyph's rounding. + let drift = (bottom(&row[1]) - row[0].pos.y) - (bottom(&own[0]) - own[0].pos.y); + assert!(drift.abs() <= 0.5, "{family:?} at {size} pt: {c} ({}) ends {drift:.2} pt below its baseline", face.family); + } + } + } + } + #[test] fn cjk_names_get_an_installed_fallback_font() { let text = "Logo 标志 ロゴ 로고"; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index bb5bf4a72..e4319809b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -23,6 +23,7 @@ mod toggles; pub use blend::{ TransparencyEdit, angle_dial, blend_dropdown, blend_param, blend_param_dropdown, blend_separator_before, opacity_blend, reference_point, + reference_point_named, }; pub use buttons::{ SPLIT_ARROW, flat_button, flat_button_fit, icon_button, icon_button_enabled, menu_item, menu_item_name, paint_icon_button, primary_button, @@ -43,6 +44,8 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; +#[cfg(test)] +pub(crate) use dropdowns::menu_floor; pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, @@ -68,7 +71,7 @@ pub use text::{ exact_text_field, exact_text_field_named, plain_field, plain_field_named, search_field, text_field, text_field_named, text_presets, text_presets_named, }; -pub use toggles::{check, check_box, check_name, check_tip, check3, radio, radio_button}; +pub use toggles::{check, check_box, check_name, check_tip, check3, check3_tip, radio, radio_button}; /// A widget id salt that needs only [`std::hash::Hash`]. egui 0.37's [`egui::AsIdSalt`] also /// requires `Debug` for its debug-only id-source registry; VectorCraft's public field helpers do @@ -237,6 +240,121 @@ pub(crate) mod accessibility_tests { let chosen = if i == 4 { Toggled::True } else { Toggled::False }; assert_eq!(role_and_state(&tree, label), (Role::RadioButton, Some(chosen)), "{label}"); } + let grid = named(&tree, "Reference Point"); + assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some("Center")), "the grid is a radio group showing the current point"); + } + + /// Screen readers find the nine squares inside the Reference Point radio group. + #[test] + fn the_reference_point_group_holds_its_squares() { + crate::i18n::set_current(crate::i18n::Lang::EN); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + theme::install_fonts(&ctx); + let mut nodes = vec![]; + for time in [0.0, 0.1] { + let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + reference_point(ui, 0); + }); + out.textures_delta.clear(); + nodes = out.platform_output.accesskit_update.map(|u| u.nodes).unwrap_or_default(); + } + let node = |id: &egui::accesskit::NodeId| nodes.iter().find(|(n, _)| n == id).map(|(_, n)| n); + let Some((group, _)) = nodes.iter().find(|(_, n)| n.label() == Some("Reference Point")) else { panic!("no Reference Point group") }; + let mut inside = vec![]; + let mut todo = vec![*group]; + while let Some(id) = todo.pop() { + for child in node(&id).map(|n| n.children()).unwrap_or_default() { + inside.extend(node(child).and_then(|n| n.label()).map(str::to_owned)); + todo.push(*child); + } + } + for label in ["Top Left", "Top", "Top Right", "Left", "Center", "Right", "Bottom Left", "Bottom", "Bottom Right"] { + assert!(inside.iter().any(|l| l == label), "{label} is in the group: {inside:?}"); + } + } + + /// A frame of a button, the reference point grid at `point` and another button, after `events`; + /// a new point from the grid replaces `point`. Returns the two buttons' ids. + fn grid_frame(ctx: &egui::Context, events: Vec, point: &mut usize) -> (egui::Id, egui::Id) { + let mut ids = (egui::Id::NULL, egui::Id::NULL); + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + ids.0 = ui.button("Before").id; + if let Some(i) = reference_point(ui, *point) { + *point = i; + } + ids.1 = ui.button("After").id; + }); + // No renderer takes the font atlas updates in a test; dropping them unapplied panics. + out.textures_delta.clear(); + ids + } + + /// Tab stops once on the reference point grid, not on each of its nine squares. + #[test] + fn the_reference_point_grid_is_one_tab_stop() { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut point = 4; + let mut ids = (egui::Id::NULL, egui::Id::NULL); + let stops = tab_stops(&ctx, 3, |events| ids = grid_frame(&ctx, events, &mut point)); + assert_eq!(stops.len(), 3, "{stops:?}"); + assert_eq!(stops[0], ids.0, "the first Tab reaches the button before the grid"); + let size = ctx.read_response(stops[1]).map(|r| r.rect.size()); + assert_eq!(size, Some(egui::Vec2::splat(23.0)), "the second Tab reaches the whole grid, not one of its squares"); + assert_eq!(stops[2], ids.1, "the third Tab leaves the grid for the button after it"); + assert_eq!(point, 4, "Tab leaves the point as it was"); + } + + /// While the grid has the focus, the arrow keys move the point one square, stop at the edges and + /// keep the focus on the grid. + #[test] + fn arrow_keys_move_the_reference_point() { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut point = 4; + let stops = tab_stops(&ctx, 2, |events| { + grid_frame(&ctx, events, &mut point); + }); + let grid = stops[1]; + let arrow = |key| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for (key, want) in [ + (egui::Key::ArrowRight, 5), + (egui::Key::ArrowUp, 2), + (egui::Key::ArrowRight, 2), + (egui::Key::ArrowUp, 2), + (egui::Key::ArrowLeft, 1), + (egui::Key::ArrowDown, 4), + (egui::Key::ArrowDown, 7), + (egui::Key::ArrowLeft, 6), + (egui::Key::ArrowDown, 6), + ] { + grid_frame(&ctx, vec![arrow(key)], &mut point); + grid_frame(&ctx, vec![], &mut point); + assert_eq!(point, want, "{key:?}"); + assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "{key:?} keeps the focus on the grid"); + } + // From no point, an arrow moves from the center. + point = 9; + grid_frame(&ctx, vec![arrow(egui::Key::ArrowLeft)], &mut point); + grid_frame(&ctx, vec![], &mut point); + assert_eq!(point, 3, "← from no point chose the middle of the left side"); + assert_eq!(ctx.memory(|m| m.focused()), Some(grid), "← from no point keeps the focus on the grid"); + } + + /// A locator under another label takes that label, without its colon, as the group's name. + #[test] + fn a_named_reference_point_group_takes_its_label() { + // Each locator gets its own id scope, so their ids differ. + let tree = tree(|ui| { + for (point, label) in [(4, "Pivot:"), (0, "Origine :"), (8, "基準点:")] { + ui.push_id(label, |ui| reference_point_named(ui, point, label)); + } + }); + for (label, value) in [("Pivot", "Center"), ("Origine", "Top Left"), ("基準点", "Bottom Right")] { + let grid = named(&tree, label); + assert_eq!((grid.role(), grid.value()), (Role::RadioGroup, Some(value)), "{label}"); + } } #[test] diff --git a/crates/ui-egui/src/widgets/blend.rs b/crates/ui-egui/src/widgets/blend.rs index 5fdf065b1..6edfab334 100644 --- a/crates/ui-egui/src/widgets/blend.rs +++ b/crates/ui-egui/src/widgets/blend.rs @@ -71,7 +71,9 @@ pub fn opacity_blend( edit = Some(TransparencyEdit::Opacity(o, Live::Released)); } }); - let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::click() } else { Sense::hover() }); + // Like a field's preset chevron, the slider's chevron is not a Tab stop: the field takes + // the same value from the keyboard, and Tab goes on to the next field. + let (r, resp) = ui.allocate_exact_size(vec2(18.0, 26.0), if enabled { Sense::CLICK } else { Sense::hover() }); ui.painter().rect_stroke(r, 2, Stroke::new(1.0, t.input_border), StrokeKind::Inside); icons::paint(ui, "chevron-right", r.shrink2(vec2(3.0, 7.0)), if enabled { t.icon } else { t.text_disabled }); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled && ui.is_enabled(), tl!("Opacity"))); @@ -92,14 +94,29 @@ pub fn opacity_blend( } /// The 3×3 reference point locator: nine squares, the eight outer ones joined by a line around -/// the box (the centre one stands alone), the current one filled. Returns a new index when clicked. +/// the box (the centre one stands alone), the current one filled. Returns a new index when a square +/// is clicked or an arrow key moves the point. +/// +/// The grid is one Tab stop. While it has the keyboard focus it shows an outline, and ←, →, ↑ and +/// ↓ move the point one square, stopping at the edges; from no point (`current` 9 or more) they +/// move from the center. Screen readers get a radio group named Reference Point, with the current +/// point as its value, holding the nine squares as radio buttons. pub fn reference_point(ui: &mut Ui, current: usize) -> Option { + reference_point_named(ui, current, tl!("Reference Point")) +} + +/// [`reference_point`] for a locator that a label other than Reference Point names, such as +/// `Pivot:`: screen readers name the radio group after `label`, without its final colon. +pub fn reference_point_named(ui: &mut Ui, current: usize, label: &str) -> Option { + let name = label.trim_end().trim_end_matches([':', ':']).trim_end(); let t = Tokens::get(ui.ctx()); // 5 pt squares with 4 pt gaps; the origin is snapped to whole points so the 1 pt lines through // the squares' centres stay crisp. let (sq, step) = (5.0, 9.0); let size = 2.0 * step + sq; let (rect, _) = ui.allocate_exact_size(Vec2::splat(size), Sense::hover()); + // The grid takes the focus; the squares take clicks only, so Tab does not stop on each one. + let grid = ui.interact(rect, ui.scope_id().with("refpt"), Sense::focusable_noninteractive()); let o = rect.min.round(); let centre = |i: usize| o + Vec2::new(sq / 2.0 + (i % 3) as f32 * step, sq / 2.0 + (i / 3) as f32 * step); let line = Stroke::new(1.0, t.text_dim); @@ -117,10 +134,22 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { tl!("Bottom"), tl!("Bottom Right"), ]; + grid.widget_info(|| { + let mut info = egui::WidgetInfo::labeled(egui::Role::RadioGroup, ui.is_enabled(), name); + info.current_text_value = names.get(current).map(|n| n.to_string()); + info + }); + if grid.has_focus() && ui.is_enabled() { + out = reference_point_keys(ui, grid.id, current); + } + // The squares go in a child `Ui` whose screen-reader parent is the group, so that the group + // holds them. + let scope = ui.scope_id(); + let squares = ui.new_child(egui::UiBuilder::new().max_rect(rect).accessibility_parent(grid.id)); for (i, name) in names.into_iter().enumerate() { let r = Rect::from_center_size(centre(i), Vec2::splat(sq)); - let resp = ui.interact(r.expand(2.0), ui.scope_id().with(("refpt", i)), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), i == current, name)); + let resp = squares.interact(r.expand(2.0), scope.with(("refpt", i)), Sense::CLICK); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, squares.is_enabled(), i == current, name)); if resp.clicked() { out = Some(i); } @@ -131,9 +160,48 @@ pub fn reference_point(ui: &mut Ui, current: usize) -> Option { ui.painter().rect_stroke(r, 0.0, Stroke::new(1.0, if resp.hovered() { t.text } else { t.text_dim }), StrokeKind::Inside); } } + if grid.has_focus() { + ui.painter().rect_stroke(rect.expand(2.0), 2.0, Stroke::new(1.0, t.accent), StrokeKind::Inside); + } out } +/// The point this frame's arrow keys move the focused reference point grid `id` to from +/// `current`, when it differs. +fn reference_point_keys(ui: &Ui, id: egui::Id, current: usize) -> Option { + use egui::{Event, Key}; + // The arrows move the point and do not move the focus. egui keeps the filter only when the grid + // also had the focus in the frame before, and applies it from the next frame; until then, an + // arrow the grid takes cancels the focus move that egui set at the start of the frame. + let filter = egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }; + ui.memory_mut(|m| m.set_focus_lock_filter(id, filter)); + let mut at = if current < 9 { current } else { 4 }; + let mut arrows = false; + ui.input_mut(|i| { + i.events.retain(|e| { + let Event::Key { key, pressed: true, modifiers, .. } = e else { return true }; + if !modifiers.is_none() { + return true; + } + let (col, row) = (at % 3, at / 3); + at = match key { + Key::ArrowLeft => row * 3 + col.saturating_sub(1), + Key::ArrowRight => row * 3 + (col + 1).min(2), + Key::ArrowUp => row.saturating_sub(1) * 3 + col, + Key::ArrowDown => (row + 1).min(2) * 3 + col, + _ => return true, + }; + arrows = true; + false + }); + }); + if !arrows { + return None; + } + ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + (at != current).then_some(at) +} + /// An angle dial `size` points across: a circle with a hand from its centre at `angle` (degrees, /// counter-clockwise from 3 o'clock). Pressing or dragging points the hand at the pointer, in whole /// degrees (-179..180), Shift in 45° steps. Returns the new angle. diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index d7ca5ab06..1046e56a7 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -252,8 +252,9 @@ pub(crate) fn popover_is_open(ctx: &egui::Context, anchor: egui::Id) -> bool { ctx.data(|d| d.get_temp::(anchor.with("popover"))).is_some_and(|f| f + 1 >= frame) } -/// Records where the bottom bars (status and hint bars) start this frame in the window being drawn, -/// so its menus can stop above them (#2086). Without bars it is the bottom of the window. +/// Records where the bottom bars (the status and hint bars, and the Control bar docked at the +/// bottom) start this frame in the window being drawn, so its menus can stop above them (#2086). +/// Without bars it is the bottom of the window. pub fn set_menu_floor(ctx: &egui::Context, y: f32) { let id = menu_floor_id(ctx); ctx.data_mut(|d| d.insert_temp(id, y)); @@ -266,7 +267,7 @@ fn menu_floor_id(ctx: &egui::Context) -> egui::Id { /// Where menus of the window being drawn stop: its recorded floor, or its bottom edge when nothing /// recorded one, as in a floating panel's OS window. -pub(super) fn menu_floor(ctx: &egui::Context) -> f32 { +pub(crate) fn menu_floor(ctx: &egui::Context) -> f32 { let id = menu_floor_id(ctx); ctx.data(|d| d.get_temp::(id)).unwrap_or_else(|| ctx.content_rect().bottom()) } diff --git a/crates/ui-egui/src/widgets/numeric.rs b/crates/ui-egui/src/widgets/numeric.rs index d38838782..e73a41707 100644 --- a/crates/ui-egui/src/widgets/numeric.rs +++ b/crates/ui-egui/src/widgets/numeric.rs @@ -1,5 +1,7 @@ use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; -use super::panel_chrome::chevron_cell; +use super::panel_chrome::{ + alt_down_in_field, chevron_cell, entry_to_focus, keep_enter_in_presets, open_presets_from_keys, presets_open, return_focus_from_presets, +}; use super::text::{ after_shift_enter, focus_field, recessed_text, select_all, select_all_on_focus, shift_enter_keeps_focus, take_armed_focus, take_dialog_focus, }; @@ -822,9 +824,10 @@ fn spin_generic( let h = 26.0; let enabled = ui.is_enabled(); ui.scope(|ui| { - // The spinner, the field and the chevron share their borders: one box in three parts. + // The spinner, the field and the chevron share their borders: one box in three parts. The + // spinner is not a Tab stop (↑/↓ in the field step it), so Tab goes from field to field. ui.spacing_mut().item_spacing.x = -1.0; - let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::click()); + let (sr, sresp) = ui.allocate_exact_size(vec2(16.0, h), Sense::CLICK); ui.painter().rect_filled(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, t.input); ui.painter().rect_stroke(sr, CornerRadius { nw: 2, sw: 2, ne: 0, se: 0 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let up = Rect::from_min_max(sr.min, pos2(sr.right(), sr.center().y)); @@ -855,30 +858,41 @@ fn spin_generic( out = Some(SpinPick::Value(spinner_step(v, step, min, up.contains(p), modifiers, stops, fmt))); } let fw = if presets.is_empty() { width - 15.0 } else { width - 34.0 }; + let keyed = if presets.is_empty() { None } else { alt_down_in_field(ui, Rect::from_x_y_ranges(sr.right()..=sr.right() + fw, sr.y_range())) }; if let Some(v) = field(ui, fw, label) { out = Some(SpinPick::Value(v.max(min))); } if !presets.is_empty() { let dresp = chevron_cell(ui, h); + let keyed = open_presets_from_keys(ui, &dresp, keyed); + let was_open = presets_open(ui, &dresp); egui::Popup::menu(&dresp).show(|ui| { ui.set_min_width(width - 10.0); - if let Some(on) = auto - && ui.selectable_label(on, tl!("Auto")).clicked() - { - out = Some(SpinPick::Auto); - } - if let Some(on) = optical - && ui.selectable_label(on, optical_label.unwrap_or_default()).clicked() - { - out = Some(SpinPick::Optical); - } let current = value.filter(|_| auto != Some(true)); - for p in presets { - if ui.selectable_label(current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p)).clicked() { - out = Some(SpinPick::Value(*p)); + let mut entries: Vec<(bool, String, SpinPick)> = Vec::new(); + if let Some(on) = auto { + entries.push((on, tl!("Auto").to_string(), SpinPick::Auto)); + } + if let Some(on) = optical { + entries.push((on, optical_label.unwrap_or_default().to_string(), SpinPick::Optical)); + } + entries.extend(presets.iter().map(|p| (current.is_some_and(|v| (v - p).abs() < 1e-6), fmt(*p), SpinPick::Value(*p)))); + let focus = entry_to_focus(keyed, entries.iter().map(|e| e.0)); + for (i, (on, text, pick)) in entries.into_iter().enumerate() { + let r = ui.selectable_label(on, text); + if focus == Some(i) { + r.request_focus(); + } + if r.clicked() { + out = Some(pick); + // Enter or Space on the focused entry is a click too, which the menu + // does not close on by itself. + ui.close(); } } }); + keep_enter_in_presets(ui, was_open); + return_focus_from_presets(ui, &dresp); } }); out diff --git a/crates/ui-egui/src/widgets/panel_chrome.rs b/crates/ui-egui/src/widgets/panel_chrome.rs index 0dcad26fe..e07367dab 100644 --- a/crates/ui-egui/src/widgets/panel_chrome.rs +++ b/crates/ui-egui/src/widgets/panel_chrome.rs @@ -117,10 +117,78 @@ pub fn layout_button(ui: &mut Ui, tiles: &[(u8, u8)], selected: bool, tip: &str) resp.on_hover_text(tip).clicked() } -/// The chevron cell on the right of a field that opens its preset list (rounded on the right). +/// The field at `field`, before a preset chevron, when it has the keyboard focus and Alt+↓ (with +/// no other modifier) was pressed. The key is taken before the field is drawn, so that the field +/// does not step on it; the caller then opens the preset list ([`open_presets_from_keys`]). +pub(super) fn alt_down_in_field(ui: &Ui, field: Rect) -> Option { + let focused = ui.memory(|m| m.focused())?; + let in_field = ui.ctx().read_response(focused).is_some_and(|r| field.expand(1.0).contains_rect(r.rect)); + let alt_down = |e: &egui::Event| matches!(e, egui::Event::Key { key: egui::Key::ArrowDown, pressed: true, modifiers, .. } if modifiers.matches_exact(egui::Modifiers::ALT)); + let pressed = in_field + && ui.is_enabled() + && ui.input_mut(|i| { + let before = i.events.len(); + i.events.retain(|e| !alt_down(e)); + i.events.len() != before + }); + pressed.then_some(focused) +} + +/// Opens the preset list of `chevron` when Alt+↓ was pressed in `field` ([`alt_down_in_field`]), +/// and remembers the field so that [`return_focus_from_presets`] gives it the keyboard focus back +/// once the list closes. Returns whether it opened the list. +pub(super) fn open_presets_from_keys(ui: &Ui, chevron: &Response, field: Option) -> bool { + let Some(field) = field else { return false }; + let popup = egui::Popup::default_response_id(chevron); + egui::Popup::open_id(ui.ctx(), popup); + ui.data_mut(|d| d.insert_temp(popup.with("return focus"), field)); + true +} + +/// Whether the preset list of `chevron` is open, read before the list is drawn for +/// [`keep_enter_in_presets`]. +pub(super) fn presets_open(ui: &Ui, chevron: &Response) -> bool { + egui::Popup::is_id_open(ui.ctx(), egui::Popup::default_response_id(chevron)) +} + +/// Takes this frame's Enter presses after an open preset list was drawn: Enter picks the focused +/// entry and does not also reach the dialog's OK, as in [`super::dropdowns`]' combo boxes. +pub(super) fn keep_enter_in_presets(ui: &Ui, was_open: bool) { + if was_open { + ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter)); + } +} + +/// After the preset list of `chevron` was drawn: when a list that Alt+↓ opened has closed (an +/// entry was picked, or Escape or a click outside closed it), the field gets the keyboard focus +/// back, unless the focus has moved on to a widget outside the list. +pub(super) fn return_focus_from_presets(ui: &Ui, chevron: &Response) { + let popup = egui::Popup::default_response_id(chevron); + let key = popup.with("return focus"); + let Some(field) = ui.data(|d| d.get_temp::(key)) else { return }; + if egui::Popup::is_id_open(ui.ctx(), popup) { + return; + } + ui.data_mut(|d| d.remove::(key)); + // The list's entries are drawn in the popup's own layer, whose id is the popup id. + let elsewhere = ui.memory(|m| m.focused()).and_then(|id| ui.ctx().read_response(id)).is_some_and(|r| r.layer_id.id != popup); + if !elsewhere { + ui.memory_mut(|m| m.request_focus(field)); + } +} + +/// The entry of a preset list that gets the keyboard focus when Alt+↓ opened the list: the +/// chosen one, else the first. `None` when the list was not opened from the keys. +pub(super) fn entry_to_focus(opened_from_keys: bool, chosen: impl IntoIterator) -> Option { + opened_from_keys.then(|| chosen.into_iter().position(|c| c).unwrap_or(0)) +} + +/// The chevron cell on the right of a field that opens its preset list (rounded on the right). It +/// is not a Tab stop: Tab goes from the field to the next field, and Alt+↓ in the field opens the +/// list ([`alt_down_in_field`]). pub(super) fn chevron_cell(ui: &mut Ui, h: f32) -> Response { let t = Tokens::get(ui.ctx()); - let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::click()); + let (dr, dresp) = ui.allocate_exact_size(vec2(20.0, h), Sense::CLICK); ui.painter().rect_filled(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, t.input); ui.painter().rect_stroke(dr, CornerRadius { nw: 0, sw: 0, ne: 2, se: 2 }, Stroke::new(1.0, t.input_border), StrokeKind::Inside); let c = if !ui.is_enabled() { diff --git a/crates/ui-egui/src/widgets/text.rs b/crates/ui-egui/src/widgets/text.rs index a8aa9e802..3551baebd 100644 --- a/crates/ui-egui/src/widgets/text.rs +++ b/crates/ui-egui/src/widgets/text.rs @@ -1,6 +1,8 @@ use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; use super::numeric::mixed_field_named; -use super::panel_chrome::chevron_cell; +use super::panel_chrome::{ + alt_down_in_field, chevron_cell, entry_to_focus, keep_enter_in_presets, open_presets_from_keys, presets_open, return_focus_from_presets, +}; use super::{Rect, Response, Stroke, StrokeKind, Tokens, Ui, id_with, vec2}; /// The flag `dialogs::show` raises while it draws the body of a dialog that has just opened; the @@ -380,16 +382,30 @@ pub fn text_presets_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option<&s ui.scope(|ui| { // The field and the chevron share a border. ui.spacing_mut().item_spacing.x = -1.0; + let at = ui.cursor().min; + let keyed = alt_down_in_field(ui, Rect::from_min_size(at, vec2(width - 19.0, 26.0))); out = text_field_named(ui, &id, name, Some(value), width - 19.0, 1); let resp = chevron_cell(ui, 26.0); + let keyed = open_presets_from_keys(ui, &resp, keyed); + let was_open = presets_open(ui, &resp); egui::Popup::menu(&resp).show(|ui| { ui.set_min_width(width - 10.0); - for p in presets { - if ui.selectable_label(*p == value, *p).clicked() { + let focus = entry_to_focus(keyed, presets.iter().map(|p| *p == value)); + for (i, p) in presets.iter().enumerate() { + let r = ui.selectable_label(*p == value, *p); + if focus == Some(i) { + r.request_focus(); + } + if r.clicked() { out = Some((*p).to_string()); + // Enter or Space on the focused entry is a click too, which the menu does not + // close on by itself. + ui.close(); } } }); + keep_enter_in_presets(ui, was_open); + return_focus_from_presets(ui, &resp); }); out } diff --git a/crates/ui-egui/src/widgets/toggles.rs b/crates/ui-egui/src/widgets/toggles.rs index 8dac694ec..5cac2023e 100644 --- a/crates/ui-egui/src/widgets/toggles.rs +++ b/crates/ui-egui/src/widgets/toggles.rs @@ -26,7 +26,12 @@ pub fn check(ui: &mut Ui, label: &str, value: bool, enabled: bool) -> bool { /// [`check`] with tooltip `tip` on the box and its label. Returns true when toggled. pub fn check_tip(ui: &mut Ui, label: &str, value: bool, enabled: bool, tip: &str) -> bool { - let (clicked, resp) = check_row(ui, tl!(label), Some(value), enabled); + check3_tip(ui, label, Some(value), enabled, tip) +} + +/// [`check3`] with tooltip `tip` on the box and its label. Returns true when clicked. +pub fn check3_tip(ui: &mut Ui, label: &str, value: Option, enabled: bool, tip: &str) -> bool { + let (clicked, resp) = check_row(ui, tl!(label), value, enabled); resp.on_hover_text(tip); clicked } diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs index 28abe518b..d7590ff3b 100644 --- a/crates/ui-egui/src/workspaces.rs +++ b/crates/ui-egui/src/workspaces.rs @@ -21,6 +21,9 @@ use crate::{VectorcraftApp, widgets}; pub struct Workspace { pub name: String, pub control_bar: bool, + /// The Control bar is docked to the bottom of the window (built-in workspaces, and workspaces + /// saved without this field: the top). + pub control_bar_bottom: bool, pub rulers: bool, pub toolbar: bool, pub toolbar_double: bool, @@ -175,6 +178,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { Workspace { name: name.into(), control_bar: ui.control_bar, + control_bar_bottom: ui.control_bar_bottom, rulers: ui.view.rulers, toolbar: ui.toolbar, toolbar_double: ui.toolbar_double, @@ -201,6 +205,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace { /// Apply a workspace's layout flags (brightness, prefs, shortcuts etc. untouched). pub fn apply(ui: &mut UiState, w: &Workspace) { ui.control_bar = w.control_bar; + ui.control_bar_bottom = w.control_bar_bottom; ui.view.rulers = w.rulers; ui.toolbar = w.toolbar; ui.toolbar_double = w.toolbar_double; @@ -514,7 +519,10 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(tl!("Saves the current bars, toolbar and panel layout.")).color(t.text_dim).size(11.0)); } ui.add_space(16.0); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // As wide as the fields and as tall as the buttons, as in `dialogs::show`: a right-to-left + // layout would take all the height left in the window and center the buttons in it. + let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y); + ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::primary_button(ui, tl!("OK")).clicked() { ok = true; } @@ -766,4 +774,23 @@ mod tests { let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); assert_eq!(back.custom_workspaces[1].dock_width, Some(260.0)); } + + /// A workspace keeps the edge the Control bar is docked to, and one saved without it docks the + /// bar at the top. + #[test] + fn saved_workspaces_restore_the_control_bar_edge() { + let mut ui = UiState { control_bar: true, control_bar_bottom: true, ..Default::default() }; + save_as(&mut ui, "Bottom").unwrap(); + switch(&mut ui, ESSENTIALS).unwrap(); + assert!(!ui.control_bar_bottom, "Essentials docks the bar at the top"); + switch(&mut ui, "Bottom").unwrap(); + assert!(ui.control_bar_bottom, "the saved workspace docks it at the bottom"); + let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap()).unwrap(); + assert!(back.control_bar_bottom && back.custom_workspaces[0].control_bar_bottom, "both survive the preferences' JSON"); + let mut old = serde_json::to_value(&back.custom_workspaces[0]).unwrap(); + assert!(old.as_object_mut().unwrap().remove("controlBarBottom").is_some()); + let old: Workspace = serde_json::from_value(old).unwrap(); + apply(&mut ui, &old); + assert!(ui.control_bar && !ui.control_bar_bottom, "an older workspace docks the bar at the top"); + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0a9bfd9f0..4e8408cd5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -685,7 +685,16 @@ art too large at that scale) keeps the dialog open. Placement Options: the Links panel's flyout (or `ui.placementOptionsDialog {ids?}` for agents) opens the `placementOptions` dialog for the selected images (fields `ids`, `preserve`: `transforms`/`bounds`/`fileDimensions`/ `fit`/`fill`, `align`: `topLeft` … `bottomRight`, `clip`). `ui.dialog.confirm` runs `links.placementOptions` with them as -one undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's +one undo step. + +Relink: the Links panel's Relink… button and flyout item (or `ui.relinkDialog {ids?}` for agents) open the `relink` +dialog for the selected images and placed documents (fields `ids`, `links`: `[{name, ids}]`, one row per linked file, +`scope`: `each`/`selected`/`all`/`missingInFolder`, default `selected`, `path` and `paths`). With nothing selected and +no `ids`, it fails with "select the images to relink, or pass ids". `ui.dialog.set {field: "path", value}` then `ui.dialog.confirm` runs +`links.relink {ids, path, scope}` as one undo step; without `path` a file is picked. With `scope: "each"`, each confirm +takes `path` (or picks a file) for the next row of `links`, adds it to `paths` and keeps the dialog open, returning +`{next}` (the next row's name); the last row's confirm runs `links.relink {scope: "each", paths}` for every row as one +undo step. The Links panel (`window.panel {panel: "links"}`) shows `links.list` and the first selected image's `links.info`; `links.editOriginal` and `links.reveal` (Edit › Edit Original, Show in Folder) hand the linked file to the system and return `{path}`. Edit Original opens only a regular file whose extension is a format `document.open` reads, and answers a control request with an error unless the file is a placed document (which opens in a tab). diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 2307add80..6fe07e086 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | -| Materials panel, lighting, ray-traced render | one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | +| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | none | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 14a233f4b..805bbc63a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -18,7 +18,7 @@ from the public specifications, and users' own `.ai` files are used locally only | | Illustrator | VectorCraft | Notes | |---|---:|---:|---| | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | -| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | +| Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | | Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | @@ -76,21 +76,42 @@ private editing data. VectorCraft: RGB or CMYK ink amounts, and their alpha channel) and linked images as placed files with their path, their folder relative to the document's and a preview; images that can't be decoded or have more than 33,554,432 pixels are written as their outlines filled mid gray, images of 16 bits a channel at 8 bits, and linked images in opacity - masks and pattern tiles as embedded images, each with a warning. Point type, area type (with its rows, columns, + masks, pattern tiles and symbols as embedded images, each with a warning. Every symbol is written as a symbol + definition (its art, its registration point and its place in the Symbols panel's order, nested symbols included) + and its instances as symbol instances with their matrix, name, hide, lock, opacity, blending mode and opacity + mask; instances with a fill, appearance changes, isolation, knockout or knockout shape of their own, instances of + symbols with 9-slice guides or pattern paint, instances in pattern tiles or in art cut to a clipping object, + instances scaled almost flat, instances that turn, mirror or scale symbols holding type, live objects, + instances written as their art, shapes with rounded live corners or strokes written as outlines or brush art, + instances that scale their symbol more than 100 times and instances on the layers of a large canvas are written as their art, pattern fills in a + symbol's art are written as their tiles cut to the shape, symbols past 4,096 are written only as their + instances' art, and symbols' 9-slice guides, export types and dynamic type are left out, each with a + warning. Paths, compound paths and groups with live effects keep them as art styles: the drawn look, the object + itself and the style it names, for a fill and a stroke at most in solid colors or the object's gradient (a stroke + with its variable-width profile), filled by the non-zero rule, with Drop Shadow, Inner Glow, Outer Glow, Feather, + Round Corners, Scribble, Convert to Shape, Offset Path, Outline Object or Outline Stroke, and groups with Pathfinder › + Add. Groups keep them when their members are paths, compound paths or such groups, without effects of their own; when the + group's effects change its members' outlines, a member's strokes are plain lines, and its fills are not pattern + fills written as tiles or gradients that fit their object. The look holds the object's vector art, without the shadow, glow or feather. Other objects with + live effects are written as their vector art with their geometry effects applied, and their other effects, such as + Drop Shadow, the glows and Feather, are left out with a warning. Graphic styles and links to them are left out with a warning. No art style is written on a large + canvas. + Point type, area type (with its rows, columns, gutters and first baseline, threaded frames included) and type on a path are written as text: a text object for each, naming its story in the text document, with the characters, fonts, sizes, leading, tracking, baseline shift, scales, solid fills and strokes, capitals and paragraph alignment, indents, spacing and direction; other character - and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles and art cut to a + and paragraph attributes are left out with a warning. Type in opacity masks, pattern tiles, symbols and art cut to a clipping object, mirrored type, turned or unevenly scaled area type and type on a path, area type in an open frame, type on an open path whose ends meet, area type that flows around a text wrap, type of only white space, threads of type on a path, threads whose frames differ in orientation, type and stories past the sizes VectorCraft's reader reads, and - type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, symbols, other clipping objects and live + type with inline graphics, ruby, gradient or pattern paint or an appearance or opacity mask of its own, other patterns, other clipping objects and live objects (placed documents included) are written as plain filled and stroked paths, and freeform gradients, gradients along or across a stroke and gradients without stops, with more than 256 stops or without extent as their average color, each with a warning. Gradients that dither or mix their colors perceptually are written as classic gradients, and gradient stops linked to global process colors as their colors, each with a warning. The opacity and blending mode of single fills and strokes, opacity masks on layers, the placement of pattern swatches that - move, turn or scale their pattern, and effects other than geometry effects are left out. + move, turn or scale their pattern, and the effects of objects written without an art style, other than geometry + effects, are left out. The editing copy also holds the document's variables, data sets, the data set shown and the variables' lock as the variable library the reader reads, with the objects bound to visibility variables, the type bound to text variables and the linked images bound to image variables. Bindings to layers, to type written as outlines and to @@ -134,6 +155,11 @@ private editing data. VectorCraft: brushes come in as their drawn look (#637; pattern fills are read since #1025, and symbols and their instances open live); legacy `.ai` without the prolog opens since #1027, its legacy type (`To` … `TO`) since #868; guides open as ruler guides and guide paths since #5057 (#779). + Type under a Warp effect is compared with the art saved with it: type that draws as that art stays editable with + the effect live, and type that draws differently, or that the file's limit of checks has no check left for, opens + as that art, with one note that counts them. Type stays live without a comparison when the saved art paints + nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text + object doesn't come in as one text object. Legacy `.ai` type keeps its paragraphs' letter and word spacing (`TC`, `TW`), hanging punctuation (`Tq`) and hyphenation (`Th`); its tab definitions (`Xb` … `XB`) set its paragraphs' tab stops. Paragraphs in a row with the same attributes share one copy of them while the text object is read, and those copies hold at most @@ -141,7 +167,17 @@ private editing data. VectorCraft: stops. Paragraphs and frames past either limit take the default stops, with a warning. Its special characters (`TS`) join the story, and end render (`Te`) and pop text matrix (`TM`) are read and ignored. Its type in a font re-encoded with encoding pairs of its own (`TZ`) is decoded with those pairs, and with the file's encoding - (`TE`) for the codes they leave out unless the `TZ` sets `useDefault` to 0. + (`TE`) for the codes they leave out unless the `TZ` sets `useDefault` to 0. Its type in a CJK composite font, + whose name ends in a predefined CMap (`HeiseiMin-W3-83pv-RKSJ-H`) and which no `TZ` gives encoding pairs, opens + with the font named without the CMap. Its codes are read by the CMap's code space, mapped to CIDs and then to + Unicode by the character collection's CID-to-Unicode CMap, and codes the CMap does not map are dropped. A `-V` + CMap is read with its `-H` form's codes, and a 0x0D byte ends a paragraph. The Unicode CMaps `Uni*-UCS2`, `-UTF8`, + `-UTF16` and `-UTF32` are read as Unicode. VectorCraft reads the predefined CMaps that craft-pdf does not carry + (`RKSJ`, `78-RKSJ`, `78ms-RKSJ`, `78-EUC`, `78`, `Add`, `Ext`, `NWP`, `GB`, `CNS1`, `KSC`, `B5` and the Hong Kong + `-B5` sets other than `HKscs-B5`) with its own choice of the nearest carried CMap, which can give a different + character for some codes, and decodes `GBT`, `GBT-EUC`, `GBTpc-EUC`, `CNS2` and `KSC-Johab` type byte by byte with + the CMap removed from the font name. A font whose name ends in `-H` or `-V` without a `TZ` that gives encoding pairs + is read with the `H` or `V` CMap. Its area type with several linked areas opens as threaded frames, one for each area with its own matrix, and the story flows through them. Its writing direction (`TV`) opens as vertical type. VectorCraft keeps one direction for each text object, the @@ -151,7 +187,9 @@ private editing data. VectorCraft: block in vertical type opens unmarked, and two blocks with nothing between them open as one block. Its upright Roman characters (`Tv`) open with Standard Vertical Roman Alignment. VectorCraft's own layout rule also sets a run of two or three half-width digits across the column in vertical type, inside or outside a block and with - either `Tv` value. + either `Tv` value. Its binding punctuation (`Tg`) opens as Burasagari's Force setting when on and as no + Burasagari when off, which is VectorCraft's own mapping; the repeat character, line breaking (`TG`) and + Japanese layout rules (`Xu`, `Tu`) are not read. Legacy `.ai` type set in rendering modes 4 to 7 (`Tr`, text used as a mask) is a clipping path of its clipping group, as a `W` path is, and the mode lasts into later text objects. Type in linked areas, in wraparound text or in a compound path doesn't clip, with a warning. A `Tr` operand that is not a whole number @@ -175,6 +213,8 @@ private editing data. VectorCraft: ### Illustrator's private editing data +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). In a PDF saved with some of its artboards, the type on each page after the first goes to an artboard of that page's size after the previous page's artboard, and only to one that leaves an artboard for each later page; the first page's artboard also leaves one for each later page. When several artboards fit a later page, VectorCraft takes the one whose art draws most like the page, and compares at most 16 artboards for all the later pages together. Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path, and a compound clipping path whose subpaths paint differently, whose filled subpaths are read as a compound path of their own under the art it clips; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; a filled clipping path keeps its fill, which paints under the art it clips, except the path of a mask that covers several layers, whose fill is read as a path of its own in the layer that holds the mask's path; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. @@ -191,6 +231,8 @@ the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPri the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic one, or a Calligraphic brush with settings of its own or an angle that follows the path comes in as its drawn look (a group named after it). Objects in a symbol defined before the file's art styles section, as newer files write it, take the same styles as the layers' objects. A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372). A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack, so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. the app's own editing copy of a file (an EPS after its `%%EOF`, a `.ai`'s `AIPrivateData` streams, a PDF's `AIPDFPrivateData` streams when it was saved with the editing data (#868), Zstandard or zlib, the EPS's in ASCII85) is not documented as a whole. VectorCraft reads its structure: layers and sublayers (names, order, visibility, lock, printing, preview, dimming, colour), groups as they were nested, compound paths (painted by their last path, or the last painted one, with the paths of groups inside them kept as their paths: #1410), clipping groups and layer clipping (several clipping paths in a clipping group, a layer or an opacity mask's art clip to their intersection, and a compound path of clipping paths stays one clip; clipping paths that would nest the art past the document's 32 levels stop the editing data from being read: #5789), object names, hidden and locked objects, fills and strokes (grey, CMYK, RGB, named and spot colours, spot colours defined in Lab with their Lab values (#1032), global process colours as global swatches at their tint; a stroke of width 0, `0 w`, on a path, a gradient-filled path, legacy type or in an art style comes in as a 0.25 pt hairline, as from PDF: #1671), linear and radial gradients, pattern fills with their patterns as pattern swatches (#1025), gradient and pattern names in the file up to their first newline (a name that another swatch, or a gradient or pattern defined earlier, already has is numbered "Sky 2"; a name without a newline is kept, and when another swatch has it, the pattern's swatch, or a named color sharing a gradient or pattern swatch's name, is numbered the same way), opacity, blend modes, isolation and knockout, embedded images where their matrix puts them, rotated ones too (#2269), with their alpha channel (CMYK images keep their ink amounts, as they do when a `.ai` opens from its PDF part, and an alpha channel that hides part of a CMYK image becomes a grayscale opacity mask), placed (linked) files as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (#1264), with the folder the file keeps relative to the document's, where the link is looked for when its path is gone, guides, and every artboard where it is, with its center mark, cross hairs, video safe areas and video ruler pixel aspect ratio. VectorCraft reads `CropAreaActive`, an `/Int` in the dictionary that holds the artboard array, as the index of the active artboard in that array, and the file opens with that artboard active; without it, or when it names no artboard that opens, the first artboard is active. It also reads the Document Raster Effects Settings (resolution, color model, background, anti-alias, clipping mask, Add Around Object and Preserve Spot Colors), the author from the header's `%%For` (an EPS's own header when the editing data's has none), swatches for the header's `%%CMYKCustomColor` and `%%RGBCustomColor` colors that the document has none of, and `%%TemplateBox` in older files; a setting with a value of another type or out of range keeps its default. Objects on Path groups open live, with their path, their objects in order along it, Pivot and Rotate; one with a dragged spacing or other options opens as a plain group. A gradient with stops in the middle runs its whole ramp across its shape, and a gradient object does not pass its fill to the next path (#1390). An object whose art style is one fill and one stroke at most (solid colours, its own gradient, a variable-width profile) with Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Offset Path, Outline Object, Outline Stroke, Convert to Shape, Tweak (only when it moves nothing) or Pucker & Bloat at 0 comes in as itself with them and their saved settings. A path, compound path or group (not a clipping group) whose only effect is Transform (not with copies or Random) or a Free Distort whose corners make a parallelogram comes in as itself with it too, when neither it nor anything in it paints with a gradient or pattern and everything in it is a path, compound path or group with no opacity mask and no effects of its own; Transform that scales, rotates or reflects needs nothing in it to be stroked. Twist as the only effect is read the same way on an unstroked path or compound path whose segments are all straight. One with more, other effects (Zig Zag, a Pucker & Bloat of another amount, a Free Distort with other corners), an effect setting that is not read (an unknown key, a missing or out-of-range value, a Scribble with a color of its own), dashes aligned to corners, a brush other than a Calligraphic or Art one, a Calligraphic brush with settings of its own or an angle that follows the path, or an Art brush other than the ones described next comes in as its drawn look (a group named after it). A path with a Calligraphic brush otherwise comes in as the path, the brush on its stroke at the stroke's weight, which scales the brush (#372), and so does a path with an Art brush, whose weight scales the art across the path, when all of these hold: the brush stretches its art along the path, unflipped, at 100% in Tints; the path's settings are the brush's own, with no entry that isn't read, no variable width and no effects; its stroke isn't a swatch; and the art is paths, compound paths and groups in solid fills, with no strokes, masks or effects, and has some length along the path. A stroke takes the brush the Brushes panel keeps under its name, and one whose name the panel doesn't keep comes in as its drawn look. Type with an Art brush keeps its drawn look under the type. A Calligraphic brush's Pressure, Tilt, Bearing and Rotation come in as those channels (a 6D Art Pen's brushes vary with the pressure, the bearing and the barrel's rotation); its Stylus Wheel varies at random. The file's Calligraphic, Scatter, Art and Bristle brushes replace the default ones in the Brushes panel, in the file's order, also when the file comes in as its PDF part (a brush library has no layers); pattern brushes and brushes it can't read are left out with a note. Type keeps its art style as a live appearance whatever its stack (an Art brush aside), so it can be retyped: each fill and stroke of the style's stack (solid colours, gradients and pattern fills, placed as the drawn look places them) with its own opacity, blend mode and effects, the effects on the whole object, and where the characters paint among them; the effects read are Warp, Offset Path, Outline Object, Outline Stroke, Transform (with copies, Transform Objects, Transform Patterns and Scale Strokes & Effects; not with Random), Free Distort, Pucker & Bloat, Twist, Zig Zag, Tweak (only when it moves nothing, the file holding none of its random outline), Drop Shadow, Inner Glow, Outer Glow, Feather, Round Corners, Scribble, Gaussian Blur, Bas Relief (its settings at their defaults) and Convert to Shape, and a style with others, or with an effect setting that isn't read, keeps the type's drawn look. Convert to Shape keeps its Rectangle, Rounded Rectangle or Ellipse, its Absolute or Relative size (a relative size in the file is added to each side) and its corner radius; a relative Ellipse, or a setting that isn't read, keeps the drawn look. The page's drawing of such a style isn't held to look like VectorCraft's where its drawn look lay. Gradients take their matrix (`Xm`, radials and skewed ones too), focal point and the order of stops sharing a ramp point; gradient meshes, a clipping path's own fill and its stroke, and the artboards' names are read too. `editingData: "always"` (`convert --editing-data`) opens a `.ai` or an Illustrator EPS from its editing data even where it draws differently from its page, which can be older, with a note. Type is made from the file's text document, whole and editable, shown or not: point type (its frame's matrix, which newer files leave out when it is neither turned nor scaled, read as none), area type (a story in several frames as threaded type) and type on a path, with its characters, fonts, size, leading, tracking, scaling, baseline shift, fill and stroke (grey, RGB or CMYK), alignment, indents and paragraph spacing (a character attribute a run doesn't set comes from its character style, then the style that one is based on, then the document's defaults); area type keeps its frame's Area Type Options rows and columns, the gutter between rows and the gutter between columns, and its first baseline (Fixed, Cap Height, Leading or x Height) with its minimum; the page's drawing of it (often in pieces) is left out. Vertical type keeps its orientation, its columns from right to left, and a run set to Tate-chu-yoko keeps that setting. When it makes the type of every text object that shows, the page's type is only compared with it, each text object keeps its own group, frame and layer, and a line break inside a paragraph stays one. Where it can't make a text object's type (a frame of another kind), that text object takes the page's type where the text document puts its lines, or else what is left of the page's type in painting order (no group around it); the page's type where made type draws stays that type's, and hidden type is left out with a note. Text wrap is read too: an object whose dictionary has the wrap options (offset and Invert Wrap), or any graphic of a legacy wraparound group (`*w` … `*W`, before or after its type, with no offset), is a wrap object, and area type below it in its layer flows around it when the file opens, threaded type included (#2763). Kerning, underline and the other character and paragraph options aren't read yet. A `.ai` saved without PDF compatibility (a placeholder page) opens from its editing data alone, its point type, area type and type on a path made from the text document. A `.ai` whose PDF part shows only text isn't taken for that placeholder when its page states an art, trim or bleed box: it opens from its editing data alone where that reads, else from its PDF part (#1387). A `.ai` whose PDF part draws nothing at all (an empty page, with or without those boxes) opens from its editing data alone where that reads too: there is no page art to check its layers against, and the empty page would lose all of it. Text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line (the frame is made taller, with a warning in the open result) (#1392). Symbols are read into the Symbols panel, in the file's symbol list order, with their art, their registration point and the instances in their art, and their instances come in live with their matrix, name, hide, lock, opacity, blending mode, isolation, knockout and opacity mask; a symbol whose art isn't read whole (type, placed files, an image it leaves out or anything else it doesn't read) isn't kept. Symbols are read only with the layers: a file that comes in as its page or PDF part (editing data with no layers, layers that can't be read, or any of the cases below) opens with an empty Symbols panel. Instances of symbols it can't read, instances with appearance changes of their own (a dynamic symbol's), pattern fills whose pattern it can't read, placed files the data keeps no preview of and anything else it doesn't read make the file come in as its page (on a layer that doesn't show, they are left out of that layer), with a warning saying what. An unread object on a shown layer whose box can be found instead comes in as a group of the page's objects inside that box, named after it, with the rest of the layers and groups kept and a warning listing what came in as the page's art; the file still comes in as its page when live art or another unread object shares that page art, when the object spans two artboards or lies on an artboard that has no page, when no page art lies inside its box, when that page art is inside a page group with a clip, mask, transparency or appearance, when the object holds a frame of threaded type, when the page art uses a pattern, image or swatch that the page lacks or that differs from the editing data's, or when the page art doesn't carry its transparency (#4806). The layers' art is also compared with the page (by lightness, so colours written as their CMYK equivalents compare alike, and by whether a pixel is inked at all); the page is used when they differ by more than 5% (away from type, when all the type is made from the text document), where one draws an object the other doesn't (away from type, whose lines may be laid out afresh), and when an EPS whose page is its art's box has art printed outside it (#735). A `.ai` is not compared with its page, and its PDF part is not imported, when its text document makes the type of every text object (at least one shows, and none is drawn by the appearance of the layer or group it is in), no object is unread, no placed file has a second placement, no live envelope keeps its drawn art, it declares no large canvas, and its artboards are its pages' trim boxes one for one, in points or in the first page's `UserUnit`: it opens from its layers, with the bleed of its pages' bleed boxes. When they differ because some objects draw differently (an object in another color, or one that only one of them draws), each top-level object of the layers that holds such a difference comes in as a group of the page's objects inside its box and the layers stay, with a warning that names the layer or sublayer of each such object as the Layers panel shows it (`` for one without a name) and how many came in from it; an export or Save As to the file's own path then needs `acknowledgeLoss: true`, and a wrap object replaced this way keeps its wrap options. The file still comes in as its page when only edges or type differ, when a difference lies outside every top-level object, when more than half of the top-level objects or more than 64 hold one, when such an object is or holds type or holds a wrap object inside a group, when the page's type of a text object that stays lies inside such an object's box, when the page's art can't replace such an object for one of the reasons above for an unread object, when matching the differences to the objects would take too much work, or when the result still differs from the page. `editingData: "always"` keeps the objects as read, and placed files at the other point their data gives are tried as well. An EPS whose page is its art's box keeps that page when it comes in through its layers, not the editing data's artboard, and a text object counts as drawn by the page by its colours, not only its lightness, so a red stroke stacked over a dark red one stays (#1102). `textAs: "outlines"` opens a `.ai` whose type shows from its PDF part, which has the outlines; `editingData: false` opens any of them as its page or PDF part only, for print pipelines (#735). A file whose import left something out (hidden text, art or layers) is never written over by an export or Save As to its own path unless `acknowledgeLoss: true` (`convert --replace-lossy`), so the original keeps what the document lacks. Where a `.ai` can't be read that way (or its layers are turned off in the import options), we read its PDF-compatible part, so Illustrator-only live objects arrive as appearance, and plain groups arrive ungrouped: that part draws each object on its own and marks only layers, clipping groups and groups with opacity, blending or a mask (those come back). Art outside the artboards isn't in that part at all (Illustrator's PDF part leaves it out), so it doesn't open; the import says so in a note (#472). Its pages still go where the editing data puts the artboards (a grid stays a grid), with the art on each, when it has as many artboards as pages, of the same sizes (#1068); the page of the artboard `CropAreaActive` names is then the active artboard. A `.ai` whose editing data has artboards but no layers also opens on that page under the same check, and its pages stay where they are. A `.ai` whose PDF part's pages are cropped to its art (each page the box of all the art, which is the editing data's bounding box) instead of its artboards opens from its layers, with its own artboards, where they draw as that page does. Its pages are laid out so that art one of them draws past its artboard (notes beside a design, which the part doesn't clip) doesn't lie over the page before it in the row, where it was taken for that page's art: the check against the editing data then found art its layers don't have there and opened the file as its PDF part, without the art outside the artboards. That part also has no paragraphs: area type is written line by line, and comes back as area type only where its font is installed and lays the lines out with the same breaks, else as a point type object per line (#508). Art off the page that a PDF does draw opens on the pasteboard. +A Scatter brush in the editing data whose last 15 numbers start with 1 comes into the Brushes panel in Tints. Each brush art section is read without its frame: an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt), closed, or open with its first corner again at the end. A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group. + ## Format by format | Format | Illustrator | Ours: open/place | Ours: save/export | Fidelity and tests | diff --git a/docs/gaps.md b/docs/gaps.md index e2c7bfed7..75f838f04 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -203,7 +203,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall of 14), open speed on large files (#758), live effects, Scatter, Art, Pattern and Bristle brushes on paths, a path's own Calligraphic 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 VectorCraft's reader reads (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, - clipping and type as text), with live effects left out and symbols and live objects written as their art + clipping, type as text and symbols), with live effects kept only on paths, compound paths and groups that + qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry effects left out, and live objects written as their art ([file-format-parity.md](file-format-parity.md)). Save As `.ai` › Version: Legacy (version 7) writes the PostScript form from the published version 7 specification, keeping layers (names, order, visibility, lock), groups, compound paths, clipping groups, gradients and diff --git a/docs/mcp.md b/docs/mcp.md index ac4ea9cb1..ce1b5885e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -380,8 +380,14 @@ explicit `ids`, which stand in for the selection, so they work with nothing sele `ids?|id?` (or `id?, ids?: … (instead of id)`), `id` is `ids` of one object: `{"id": 7}` does what `{"ids": [7]}` does, with or without a selection, and an `id` or `ids` that is malformed (not a non-negative integer) or names no object is refused under the command's name, never replaced by the selection (`effect.apply {"id": "warp.arc"}` -without `effect` names the effect). A command that leaves out an object selected inside a selected group also -leaves it out when `ids` list it together with a group that holds it. +without `effect` names the effect). Other commands that act on `id` or else the selection, the highlighted layer row +or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, `graphicStyle.new`, +`graphicStyle.redefine`, `charStyle.new`, `charStyle.apply`, `charStyle.redefine` and their `paraStyle.*` +counterparts, `image.info`, `links.info`, `links.goTo`, `links.unembed`, `shaper.release`, `shaper.expand`, +`transparency.editOpacityMask`, `transparency.viewOpacityMask`, `symbol.edit`, the `object.blend.spine.*` commands +and `path.curvature`) refuse an `id` that is not a non-negative integer (`"abc"`, `"5"`, `true`) with an error and +change nothing; an omitted or null `id` keeps the default. A command that leaves out an object selected inside a +selected group also leaves it out when `ids` list it together with a group that holds it. Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). @@ -410,7 +416,7 @@ Lines start with no fill and a visible stroke. The Fill/Stroke proxy commands run through `run_command`: `paint.invert` and `paint.complement` recolor the active proxy, including a grayscale image's colorized fill, while keeping each color's model. `paint.lastColor` / `paint.lastGradient` re-apply the last solid color or gradient, and `paint.recent` returns the recent colors that every -paint command (and the eyedropper) feeds. +paint command (and the eyedropper) feeds: up to 30, newest first and each once, kept across documents for the session. `paint.proxies` returns what the proxies show: the fill and stroke, which one is active, and whether the selected objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" proxy). @@ -446,7 +452,7 @@ opens the picker through `run_command` with the remote backend. | `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. | | `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. An empty CSV cell or a `null` in `rows` is a blank value; a number in straight quotes is a label. Edit later with `graph.setData` / `graph.setType` via `run_command`. `graph.column {design, columnType: vertical\|uniform\|repeating\|sliding, each, fractions: chop\|scale}` draws the columns or bars of the series picked with Group Selection (or `seriesIndexes`, or all of them) with a graph design saved by `graph.design`; with no `design` it returns the series' current design and options. Type in the design that holds a `%` followed by two digits shows each column's value, with at least the first digit's places before the decimal point and the second digit's places after it. | | `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. | -| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | +| `undo` / `redo` | `{}` | Many calls that change nothing record no undo step, for example `object.align` with nothing to move, `object.move` by 0 without `copy` and `object.showAll` with nothing hidden. `undo` (`edit.undo`) after such a call undoes the step before it. `inspect_document` lists the undo steps as `history`. `run_command` with `edit.clearHistory` removes every undo and redo step of the active document and returns `{cleared}`, the number of steps removed; the document stays as it is, and the command records no undo step. `run_command` with `edit.newDocumentFromState` and `{index?, created?}` makes a new document holding the art as it was at that state and makes it the active document, and returns `{index, title}`. `index` is a row of the History panel, with 0 the oldest state kept, and an omitted `index` uses the current state. An `index` that names no state is an error and changes nothing. `created` is File Info's created date in Unix seconds or null, and defaults to now. The new document takes the next `Untitled-N` name, is marked unsaved, has nothing selected and has no steps to undo or redo. It is out of symbol, opacity-mask and pattern editing, and the art drawn in a pattern's tile is stored in the pattern, as `object.pattern.done` stores it. The source document and its steps stay as they are, except that typing or a drag in progress is first kept as a step. While the Type tool edits type, `undo` and `redo` put its caret, with no characters selected, at the end of the text the step changed: after the text it puts back or retypes, or where the text it takes out was. A step that changes no characters, such as a formatting change, leaves the caret and the selection where they are. | For example, create editable Japanese area type with the engine's vertical writing mode: @@ -662,6 +668,10 @@ them. `grays: "black"` puts RGB greys with R = G = B on the black plate instead, Convert to Grayscale before the switch would give); other colors convert as usual. The reply's `changed` counts the colors that changed in the art, symbols, graph designs and pattern tiles; it does not count the colors of graphic styles and brushes. +`edit.colorSettings`, `file.documentColorMode`, `color.convert`, `color.gamutCheck`, `color.loadProfile`, +`color.removeProfile`, `view.proofSetup`, `view.proofColors`, `view.overprintPreview`, `view.separationsPreview` and +`swatch.setSpot` reject a param of the wrong type, such as `"bpc": "no"`, `"on": 1`, `"to": 5` or `"colors": "#fff"`, +and change nothing. Omitting a param or giving null keeps its default. SVG import keeps what the canvas edits live. `` becomes a pattern swatch. A `` with `` becomes a symbol (named after its `data-name`, else its id) with an instance per ``; a `` that shows it differently @@ -836,6 +846,9 @@ empty `boxes`, and `thumbnail: 1` shows what that page prints. A page whose `Use opens at its size in points, the file's numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size. +`document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` +that is not a string, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number. The error names the param, +and nothing opens or is placed. Leaving a param out or giving null keeps its default. Imported colours keep their model: DeviceCMYK and CMYK ICC colours stay CMYK (a file painted mostly in CMYK opens as a CMYK document), DeviceGray is Gray, and Separation and DeviceN inks become spot swatches the art links to at its tint (gradient stops too). CMYK images @@ -1227,6 +1240,14 @@ in `object.envelope.resetWithMesh`; `antiAlias`, `distortAppearance`, `distortLi `distortPatternFills` in `object.envelope.options`; and `editing` in `object.envelope.editContents`. A null is read as a key left out. +`object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` that is not a string with +`` `style` must be a string `` and a name that is no warp style with `` unknown warp style `foo` ``, and an +`orientation` other than `horizontal` or `vertical`, a number or `true` included, with +`` `orientation` must be one of horizontal, vertical ``. `object.envelope.options` refuses a `preserveShape` that is +not a string with `` `preserveShape` must be a string ``, and checks the warp keys when no envelope or only mesh +envelopes are selected, too. Each leaves the document, its undo history and the options new envelopes start with as +they were. A null is read as a key left out. + These commands refuse a string, a number or another value that is not true or false with `` `closed` must be true or false ``, naming the key, and leave the document and its undo history as they were: `clockwise` in `shape.spiral`; `closed` in `shape.arc` and `path.create`; `vertical` and `placeholder` in `text.create` @@ -1717,6 +1738,13 @@ already walks Image Trace. Short agent entry points: `symbol.selectInstances {name}`, `symbol.delete {name | names, expandInstances?}` (`names` deletes several in one undo step). `symbol.new` and `symbol.duplicate {name, newName?}` trim the white space around the name they are given, as `symbol.options` trims `newName`; a blank one gives "New Symbol" or " copy". +- Reset Transformation (the Symbols panel menu, and Reset on the Control bar while symbol instances are selected): + `symbol.instance.resetTransform {ids?}` gives the selected symbol instances, in groups too, their symbol's own + scale again, without rotation, shear or reflection, as one undo step. VectorCraft's own rules: each instance keeps + the center of its bounding box in place; other objects, instances in perspective and instances already at their + symbol's scale are left as they are, and no undo step is added when nothing changes; an id that names no object, or + objects without a symbol instance, is an error. It returns `{ids}`, the instances it changed. The menu item and the + button are enabled only while a selected instance would change. - Dynamic symbols: an instance of a symbol whose `type` is `dynamic` changes the appearance of objects in the symbol's art for itself only and stays linked. `symbol.instance.setAppearance {id?, path, fill?, stroke?, opacity?, blend?}` names the object by `path`, child indices in paint order from the art's root down through its groups (`[]` is the @@ -1760,7 +1788,9 @@ already walks Image Trace. Short agent entry points: what that kind of corner draws when the brush has no `outer_corner` or `inner_corner` art for it: `none` draws no corner tile, and `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap` make one from the side tile. Without a source, the corner draws the other kind of corner's art. `brush.options {params: {outer_corner: null}}` removes - the art. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`, + the art. Outer and inner corner art are both drawn as the corner that joins a side tile on its right to one below + it. The brush turns each corner tile so that those two arms meet the sides at the corner: 90° from one corner to + the next on a rectangle, and halfway between the two sides at other angles. Pattern Brush Options also accepts `side_tile`, `outer_corner_tile`, `inner_corner_tile`, `start_tile` and `end_tile` in `params`. Each takes `none`, `original` or the name of a pattern swatch in the active document, and `{"pattern": ""}` names a swatch even when its name is one of those words. Corner tiles also take `autoCentered`, `autoBetween`, `autoSliced` and `autoOverlap`. A swatch choice copies its whole art and tile frame into the brush. `original` @@ -2199,6 +2229,21 @@ The document stores their numeric settings; manual edits remain possible through {"name":"run_command","arguments":{"command":"effect.lightingPreset","params":{"index":0,"item":null,"preset":"topLeft"}}} ``` +## 3D object type + +`effect.setParams {index: int, threeDType: "plane" | "extrude" | "revolve" | "inflate", params?, item?, ids?, aligned?}` +changes an applied 3D effect into Rotate, Extrude & Bevel, Revolve or Inflate in place, as the object types of +**Window › 3D and Materials** do ([3D and Materials panel](three-d.md#3d-and-materials-panel)). The settings both +effects take keep their values (rotation angles are wrapped into -180..180 when the new type is not Revolve), the +settings only the new effect takes start at its defaults, and `params` then merge into the new effect's settings, +checked as that effect's. It is one undo step. Any other `threeDType` value, or an effect at `index` that is not a +3D effect, is an error and changes nothing. The type the effect already has, with no `params`, `visible` or +`warpStyle`, changes nothing, records no undo step and returns `{ids: []}`. + +```json +{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"threeDType":"inflate","params":{"volume":70}}}} +``` + ## Extrude & Bevel, Inflate and Rotate `effect.apply {effect: "threeD.extrude" | "threeD.inflate" | "threeD.rotate", params: {...}, ids?, item?}` applies @@ -3592,7 +3637,11 @@ control channel's `ui.drag` scrubs), `showHomeScreen` (on by default: the Home s off, an empty window, and the Home button or `app.home` still shows the screen), `autoCollapseIconPanels` (off by default: on, a click away from a panel popped out of the icon column puts it away), `displayPrintSize` (off by default: View › Actual Size / `view.actualSize` is one document point per screen point; on, one document inch fills -96 screen points, an inch at the system's reference density whatever the display scaling). `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features +96 screen points, an inch at the system's reference density whatever the display scaling). The tool shortcut +preference `holdToolKeys` also needs the desktop app. It is on by default: a single-key tool shortcut held for 0.25 s +or more, or held while its tool is used, chooses the tool until the key is let go and then returns to the tool active +before, after any drag in progress; a quicker press chooses the tool for good. With it off, every press and every key +repeat chooses the tool. `highlightAlternateGlyphs` (off by default: on, the canvas paints a pink cell behind glyphs OpenType features substituted — ligatures, contextual forms — on screen; Document Setup's Highlight Substituted Glyphs stays separate). `highlightSubstitutedFonts` (on by default: the canvas paints pink behind text drawn in a substitute for a missing font, in every document). `blackOnScreen` @@ -3650,6 +3699,11 @@ Guides change where a snapped point lands: object under the pointer (the gap between their facing edges on an axis on which they lie apart, else the offsets of their two edges on it) or, over no object, to the left, right, top and bottom edges of the artboard under the pointer: measured lines labelled in the General unit. Off, Alt-hovering shows none. +- `snapToLastLocation` (Snap to Last Location, off by default): with Smart Guides on, a selection moved by dragging + (Selection, Group Selection or Direct Selection tool / `pointer_gesture` with `selection`, Shift too) goes back to + where the move began while the move is no longer than `snappingTolerance`, labeled "last location"; farther, it + snaps as before. An Alt copy is not pulled back onto its original, and with View › Snap to Grid or Snap to Pixel on + the move snaps to the grid or to whole pixels instead. A value that is not a boolean is refused. The Snap to Glyph options (the desktop app's Character panel menu › Show Snap to Glyph Options; View › Snap to Glyph is the desktop app's): `view.snapToGlyph.options {baseline?, xHeight?, bounds?, proximity?, angular?, anchors?}` (or @@ -3860,6 +3914,12 @@ first; no spaces), stored back as the object name. `typeSizeIncrement`, `baselineShiftIncrement`) with `measure: "general"|"type"`; they are kept in points and also take a string with a unit. Unit names: `Points`, `Picas`, `Inches`, `Millimeters`, `Centimeters`, `Pixels`, `Feet & Inches`, `Meters`, `Yards`, `Feet` (the preferences use `points`, `picas`, … `feetInches`, `meters`, `yards`, `feet`). +`edit.preferences {category?}` opens the Preferences dialog (`preferences`) with its search field focused. Its +`__search` field (`ui.dialog.set`) holds the search text: while it has text, the category list is replaced by the +settings whose label, section or category contains it, ignoring case, in the interface language or in English, or by +"No settings found". Choosing a setting sets `__category` to its category, sets `__found` to its key (the row is +highlighted until another category is shown) and clears `__search`. Cmd/Ctrl+F focuses the field again; Escape in the +field with text clears it, and Escape in the empty field closes the dialog. ```json {"name":"run_command","arguments":{"command":"prefs.set","params":{"key":"unitsGeneral","value":"millimeters"}}} @@ -3937,6 +3997,12 @@ with an `index` it closes the document and all its views. `window.nextDocument` through the views in tab order and add `view` to their reply for a document with more than one view. `ui.inspect` reports `windows` (every window as `[document index, view number]`, in tab order), `currentWindow` and each pane's `view`. Views are not saved. +`window.control.dock {edge}` (desktop app) docks the Control bar to the top of the window, under the menu bar +(`edge: "top"`, the default), or to its bottom (`"bottom"`), and returns `{edge}`. The bar's panel menu runs it with +Dock to Top and Dock to Bottom, the edge in use checked, and so does a drag of the bar's gripper, which docks the bar at +the edge the pointer is released nearer and shows a band along that edge while it moves. The edge is kept with the +workspace, and a workspace saved without it docks the bar at the top. Another `edge`, or none, fails with "`edge` must +be one of top, bottom" and leaves the bar where it is. ```json {"name":"run_command","arguments":{"command":"file.new","params":{"preset":"A4","orientation":"landscape","artboards":4,"artboardLayout":{"columns":2},"bleed":9}}} @@ -4178,6 +4244,16 @@ is the width of one video pixel over its height, which `view.videoRulers` counts the native format keep both (written only when on or not 1), and `inspect_document` adds `showVideoSafeAreas: true` and `videoRulerPixelAspectRatio` to an artboard that has them. +Artboard Options' Global checkboxes are application preferences, not artboard properties: `fadeRegionOutsideArtboard` +(Fade Region Outside Artboard) and `artboardUpdateWhileDragging` (Update While Dragging), both on by default +(`prefs.set {values: {fadeRegionOutsideArtboard: false}}`; also Edit › Preferences › General › Artboard Options). With +the first on, the canvas draws everything outside the artboards darker while the Artboard tool is active, the pasteboard +and the art on it: each color channel at 60% of its value, VectorCraft's own amount. With the second off, that area +draws as usual while the Artboard tool moves or resizes an artboard and darker again when the drag ends. When crossing +artboards would leave more than 4,096 separate rectangles in view, only the area outside the box around all the +artboards draws darker. Presentation Mode, exports and print do not change. The user guide describes both checkboxes under Artboard options +(https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf). + ```json {"name":"run_command","arguments":{"command":"artboard.copy","params":{"index":0}}} {"name":"run_command","arguments":{"command":"edit.paste","params":{}}} @@ -4289,9 +4365,11 @@ is mixed with middle gray by its alpha. A placed image linked to its file (`file.place {path}`, Link on) records the file's absolute path, its size, modification time and hash, and keeps a low-resolution preview (at most 256 px a side): a `.vectorcraft` save writes -the preview instead of the pixels, plus the path relative to the saved file. `document.open` reads +the preview instead of the pixels, plus the path relative to the saved file. Links inside symbols, graph designs, +patterns and opacity masks are saved with their relative paths too. `document.open` reads the linked files again, looking for each at its path, then at its relative path and by name in the document's folder -(so a folder moved with its links still opens). Only files on this computer, or on the network share the document is +(so a folder moved with its links still opens), and each link, in the layers or inside that art, takes the path where +its file was found. Only files on this computer, or on the network share the document is on, are looked at: a path to another computer, a device or a URL counts as missing. On Windows, a document opened from a mapped drive (`Z:\…`) is on the share the drive is mapped to, and links on that share (`\\server\share\…`) are looked at. A link on a drive mapped to another share (`Y:\…`) counts as missing, from a document on this computer too. @@ -4301,7 +4379,19 @@ The result lists `missingLinks` path isn't followed) when it isn't. The desktop app also checks the active document's links when its window comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at -another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. A PNG, JPEG, +another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as +one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, +`path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, +`path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are +errors, and nothing is relinked. Each call is one undo step. The Links panel's Relink… opens the `relink` dialog +(`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, +another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. +`links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed +documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for +those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it +again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be read", and `links.update`, or `document.open` when it reads the file, lists it as missing, its images keeping what they showed. Without a file system (the web), linked images show their previews. A link whose path has a colon and no `/` @@ -5126,7 +5216,11 @@ turn it with the object while its path geometry is still baked, so after a rotat stay square to it, as in the reference app. `object.rotate {angle, absolute: true}` turns the selection to an angle (the Transform and Properties panels' Rotate field), and `object.setBounds` measures width, height and the reference point along the turned box (x, y stay page coordinates). Objects turned together share the box. A new group starts -square to the page. `object.resetBoundingBox` squares the box again without moving the art. +square to the page. `object.resetBoundingBox` squares the box again without moving the art. It also squares every +object inside the selected ones: the children of groups, compound paths, clip groups, blends, envelopes, repeats and +intertwines, and the art of linked opacity masks (an unlinked mask's art keeps its angle). Ungroup, Release Clipping +Mask, Release Compound Path, Blend Expand, Release Envelope, Release Repeat, Release Intertwine and Release Opacity Mask +then bring back boxes square to the page. ```json {"name":"run_command","arguments":{"command":"object.rotate","params":{"angle":45,"absolute":true}}} @@ -5435,8 +5529,8 @@ Force) of the selected type (or `ids`): an East Asian comma or full stop ending 、。) hangs outside the frame, Regular only when it doesn't fit, Force always (the rest of the line then fills the measure). Closing brackets and Latin punctuation don't hang; point type, right-to-left and hyphenated lines are left as they are. The hanging mark is as wide as its punctuation spacing makes it (`mojikumi`). New type (the Type tools, -`text.create`) takes `standard`; documents from before it and imported text keep `none`, and `none` isn't saved. One -undo step. +`text.create`) takes `standard`; documents from before it and imported text keep `none`, except legacy `.ai` type +with binding punctuation (`Tg`) on, which opens as `forced`. `none` isn't saved. One undo step. ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"burasagari":"forced"}}} @@ -5872,7 +5966,18 @@ paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and `paraStyle.unused` (Select All Unused in the Character Styles and Paragraph Styles panel menus) list the styles with no uses, in panel order and never the Normal style, and the panel highlights them; `charStyle.delete` and -`paraStyle.delete` take `{names}` to delete several styles in one undo step. A Return the Type tool types (or `text.editRange` +`paraStyle.delete` take `{names}` to delete several styles in one undo step. `charStyle.load` and `paraStyle.load` +(Load Character Styles… and Load Paragraph Styles… in the panel menus, where `ui.loadCharStyles` and +`ui.loadParaStyles` pick the file) copy the styles of that kind from another document VectorCraft opens (`path`, `data` +or `dataBase64`), and `textStyle.loadAll` (Load All Styles…, `ui.loadAllStyles`) copies both kinds, each in one undo +step. The swatches and patterns the styles paint with, the symbols and brushes those patterns use, and the styles of +either kind that type in those patterns and symbols is set in, come along as Paste brings those of pasted type; a global or spot color whose name the document gives another color +is the document's. A loaded style that is the same as the style of +its name, or of its name with a number (`Body 2`), is not added again; any other is added under a free name. The +document's styles and the type that uses them keep their attributes, and the file's Normal styles are not loaded. The +reply lists the names `added` and `existing` in the document and the names `skipped` in the file (a style whose +attributes do not read or are out of the range `charStyle.new` takes, or whose name is empty); `textStyle.loadAll` +replies `{charStyles, paraStyles}`, and `charStyle.load` and `paraStyle.load` list only the styles of their own kind. A Return the Type tool types (or `text.editRange` inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style; `text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as the story re-flows (a paragraph split between frames has them in both). @@ -6004,6 +6109,10 @@ the entry's `hash` as `hash` so that a module that changed since is refused. Wit an error naming the permissions needed. The grant is kept with the module's hash; a module with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. +A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write +log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log +under its id. A module that imports anything else, or imports these without `api` 2 and `progress`, is refused by +`plugin.install` and `plugin.inspect` with an error naming the import. `plugin.install` rejects a `replace` or `allowHeavy` value that is not a boolean, a `grant` that is not a list of permission names, and a `hash` that is not the module's SHA-256. `prefs.set` returns an error and changes nothing when a new `pluginsFolder` is missing, is not a folder or can't be listed. It does not check an empty @@ -6302,6 +6411,13 @@ envelopes, the path for a top-object envelope); the content keeps the envelope's opacity mask. Type inside an envelope distorts as its glyph outlines, run by run in each run's paint, on the canvas and in every export (SVG, PDF, EPS, EMF/WMF, DXF). +An envelope maps type from the bounds of its glyph outlines, underlines and strikethroughs instead of its laid-out +box (ascent to descent, or an area's frame) when the type is on its own, in groups and layers, or used as a clipping +path. Inline graphics in the type count by their boxes. Rotated type and type in perspective map from the bounds of +their outlines as they are drawn. `object.envelope.makeWithMesh`, `object.envelope.makeWithTopObject` and +`object.envelope.resetWithMesh` with `maintainShape: false` use those bounds. Type that draws nothing keeps its +laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is +VectorCraft's own rule, and envelopes of type in saved files redraw by it. Everything inside bends with the envelope: images as a raster mesh warp (cut into pieces by Fidelity, each a clipped image under its own affine map; SVG shares the pixels between the pieces), symbol instances as their symbol's art, and with Distort Appearance strokes (as their filled outlines) and geometry effects; with it, Distort Linear Gradients @@ -6423,7 +6539,19 @@ shows, and so do the click-to-size dialogs (`perspective.draw` with `at`: the cl `object.blend.make` blends the selected objects (or `ids`) into a live blend. Spacing and orientation default to what `object.blend.options` set with no blend selected (`object.blend.info` reads them: `target: "defaults"`), -else Smooth Color and Align to Page. `starts` gives, per object, the anchor of its first subpath the blend starts +else Smooth Color and Align to Page. Smooth Color counts the steps between each pair of keys on its own, from the +keys' colors and visible bounds rather than along the spine. The counts below are VectorCraft's own values. The +colors compared are each key's solid fill and stroke, the fills and strokes of two text objects' runs at the same fraction of their characters, and +those of two groups' members paired in order. When they differ, the count is 255 times the largest change of one +channel as a fraction of its range, a half step rounding down, from 1 to 254. An RGB channel's range is 256 values, +so pure red to pure blue makes 254 steps. CMYK and gray count by their percentages, two tints of the same swatch by +their tints, and colors in different spaces (or Lab) by their RGB values over 255. The count is then at most eight +per point of the largest of the keys' widths and heights and the distance between their centers along either axis, +and when a key has no bounds it is the color count alone. When the colors match, a solid paint meets no paint, or a +key holds a gradient or a pattern, the count is the largest distance between the keys' corresponding visible +bounding-box edges divided by their mean width (left and right edges) or height (top and bottom), rounded, less one, +from 1 to 1000. Keys with no width or no height are measured by their other side, keys with neither in points, and +keys without bounds make 1 step. `starts` gives, per object, the anchor of its first subpath the blend starts from (as the Blend tool's clicks on anchor points do; an open path's last anchor runs it the other way). With the Blend tool, `press_key` Escape drops the object clicked first and stops the next click adding to the blend just made; the document and the selection stay as they are. A blend diff --git a/docs/plugins.md b/docs/plugins.md index e92a5a11e..4b29b5ef2 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -26,7 +26,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | Command | Params | What it does | |---|---|---| -| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, permissions, hash}], "folder": report}` (`budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | +| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | | `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | @@ -83,7 +83,7 @@ The host is [`wasmi`](https://github.com/wasmi-labs/wasmi), a pure-Rust WebAssem | Limit | Default | On violation | |---|---|---| -| Host imports | none: no WASI, file system, network, clock or randomness | module rejected at install | +| Host imports | none at API level 1; at level 2 only the three [host imports](#host-imports-api-level-2): no WASI, file system, network, clock or randomness | module rejected at install, naming the import | | Module size | 32 MiB | rejected | | Linear memory | 512 MiB per instance (input and parameters: at most half) | `memory.grow` returns -1; oversized input or initial memory fails | | Output | 64 MiB of JSON | `Limit("output size")` | @@ -134,6 +134,7 @@ capability model proposed for PhotoCraft plug-ins | `effect.render` | effect | Change how the objects its effect is applied to are drawn. | | `export.write` | exporter | Write the document as a file in its own format. | | `import.read` | importer | Read files you open in its format and make documents from them. | +| `progress` | all | Show its progress, see Cancel and write log lines through the [host imports](#host-imports-api-level-2). Needs `api` 2. | - A manifest without `permissions` gets the ones its kind implies, as plug-ins did before the key existed: a filter `doc.read`, `doc.write` and `state`; an effect `doc.read` and `effect.render`; an exporter `doc.read` and @@ -141,9 +142,9 @@ capability model proposed for PhotoCraft plug-ins A filter whose list is empty may do none of these, and also installs without a prompt. - An effect, an exporter or an importer that lists permissions lists all of its implied ones. A filter may list any of its three. - An unknown name, a name listed twice, a name that doesn't apply to the kind, more than 16 names, or one of the names - kept for a later plug-in API (`progress`, `ui.panel`, `ui.tool`) is refused when the module is installed, and the - error names it. + Any kind may add `progress`, which no kind implies. An unknown name, a name listed twice, a name that doesn't apply + to the kind, more than 16 names, `progress` with an `api` below 2, or one of the names kept for a later plug-in API + (`ui.panel`, `ui.tool`) is refused when the module is installed, and the error names it. - **Installing asks first.** Install Plug-in… and File › Open show the plug-in's name, id, version, the SHA-256 of the file and each permission in plain words, and install it once the user clicks OK, if the file still has the SHA-256 shown; Cancel installs nothing. The @@ -153,15 +154,18 @@ capability model proposed for PhotoCraft plug-ins `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that - lists more asks again. `plugin.remove` forgets the grant. When a `Plug-ins` folder is in use, only the grants kept + lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or + removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than + 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` install only plug-ins whose permissions were granted, and report the others. - The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. -- `api` is the plug-in API level the manifest is written for: `1` (the default) is the only level this host - implements, and a higher one is refused. +- `api` is the plug-in API level the manifest is written for: `1` (the default) or `2`, which adds the [host + imports](#host-imports-api-level-2). A higher one is refused. `plugin.list` and `plugin.info` give it as `api`. - Parameter declarations are unchanged by permissions (`params`, below). Every run gets a **fresh instance**, so no state survives between calls. Traps, exhausted budgets, malformed @@ -184,8 +188,9 @@ geometry: a plug-in runs again only when one of them changes. ## ABI v1 A plug-in is a core WebAssembly module (MVP + the usual post-MVP features: bulk memory, sign extension, multi-value, -saturating conversions; no SIMD, no memory64) with **no imports** and these exports. Integers are `i32` unless noted; -pointers are offsets into the module's memory. +saturating conversions; no SIMD, no memory64) with these exports and no imports other than the [host +imports](#host-imports-api-level-2) of API level 2. Integers are `i32` unless noted; pointers are offsets into the +module's memory. | Export | Signature | Meaning | |---|---|---| @@ -195,6 +200,34 @@ pointers are offsets into the module's memory. | `vc_alloc` | `(size) -> ptr` | A block of `size` bytes the host may write, or `0` on failure. Never freed: each run gets a fresh instance. | | `vc_run` | `(input, input_len, params, params_len) -> i64` | Runs the plug-in on the input JSON with the parameters JSON; returns `(len << 32) \| ptr` of the output JSON, or a negative error code. | +### Host imports (API level 2) + +A plug-in whose manifest gives `"api": 2` and lists the `progress` [permission](#permissions) may import these +functions from module `env` (the module a Rust or C toolchain gives `extern` functions by default). `vc_abi_version` +still returns `1`. + +| Import | Signature | Meaning | +|---|---|---| +| `vc_progress` | `(done: i64, total: i64)` | `done` of `total` steps are finished; `total` 0 or less when not known. Negative values count as 0, and `done` is clamped to `total`. | +| `vc_cancelled` | `() -> i32` | `1` once the user cancelled the run or its time budget ran out, else `0`. | +| `vc_log` | `(level, ptr, len) -> i32` | Writes the UTF-8 text at `ptr` as a log line of `level` (0 error, 1 warning, 2 information, 3 debug). Returns `0` when written, `1` when the run's log budget is spent and the line was dropped, `-1` when the text doesn't lie inside the module's memory, `-2` when it is not UTF-8, `-3` for another level. | + +- The host checks the imports when the module is installed: an import it doesn't offer, one with another signature, + and one the manifest doesn't allow (an `api` below 2, or no `progress` permission) refuse the module, and the error + names the import. +- A filter's or an exporter's progress is shown as a share: a heavy filter's progress window and the export's progress + window draw a bar with a percentage, and Cancel there makes `vc_cancelled` return `1`. A plug-in that checks it can + stop early, and the host discards what it returns after the Cancel or after its time budget ran out; one that + ignores it is still stopped by the instruction and time budgets. With a known total, a report is passed on only when + it reaches another tenth of a percent; with an unknown total, each change of `done` is passed on. After 2,000 reports + in a run, one change in 1,000 is passed on, and so is the first report after the total becomes known or unknown. A + live effect's progress is not shown, since it is drawn as part of something else. +- Log lines go to the app's log (target `vectorcraft_plugins`) as `: `, with control and + bidirectional characters escaped. The whole text must be UTF-8. A line shows its first 1,024 bytes, as at most 1,024 + characters with the escapes, and ends with `…` when it is cut. A run writes at most 200 lines and 32 KiB of shown + text, and the host reads at most 1 MiB of log text in a run; past any of these, one "log truncated" line is written + and the rest are dropped. + ### Manifest ```json @@ -400,12 +433,12 @@ crates/plugins/example/build.sh # cargo build --release --target wasm32-unk The script also refreshes `crates/plugins/tests/fixtures/desaturate.wasm`, the test fixture built from that source. The crate is not a workspace member. Any language that targets core WebAssembly works (C, Zig, AssemblyScript, -WAT…), as long as the module has no imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never +WAT…), as long as the module imports nothing but the host imports: in Rust, `std` on `wasm32-unknown-unknown` is fine as long as it never touches WASI. `vectorcraft_plugins::wat::module` is the skeleton of a plug-in in WebAssembly text, which the tests build their plug-ins from; `wat::echo_exporter` is an exporter whose file is its input or its parameters (with a base64 encoder written out in WebAssembly text), which the tests read back what the host hands a plug-in with, and `wat::when_given` a plug-in whose output depends on whether its input or parameters hold a given text, which shows in a -live effect's geometry. +live effect's geometry. `wat::reporting` and `wat::cooperating` build level 2 plug-ins that import the host functions. ## Performance @@ -415,9 +448,9 @@ per change of their input, parameters or paints (results are cached), not per fr ## Not in v1 -- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, host - callbacks (a plug-in reporting progress or asking whether it was cancelled), and importers that place files - (File › Place reads only the built-in formats) or make type, images, layers or artboards. +- Tool plug-ins (pointer events on the canvas, a guide line or rectangle, geometry back), panel plug-ins, and + importers that place files (File › Place reads only the built-in formats) or make type, images, layers or + artboards. - Live effects that change paint (they reshape geometry; an object filter recolours), and filters on type, images or symbols. - Apply Last Effect and Last Effect work for plug-in effects; there is no "last filter" for object filters yet. diff --git a/docs/roadmap.md b/docs/roadmap.md index c4246c711..d01c002ae 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -89,7 +89,7 @@ Checked 2026-10-10 from the code, tests and open issues; no workflow fails, so V | Set type: point and area type, Character and Paragraph styles, threading, outlines | yes | type core 80%; styles, threading, Create Outlines and font matching tested ([type-parity.md](type-parity.md)); justification options missing (not blocking) | 0 | | Organise layers and artboards and export assets (Export for Screens, SVG, PNG, PDF per artboard) | yes | Layers panel rework, Artboards panel and tool, Export for Screens and Asset Export done; Art Optimized supersample closes ghost borders (#983 / #1037) | 0 | | Print production: CMYK and spot colour, bleed and marks, PDF/X out, separations preview | yes | PDF/X-1a/3/4, presets, marks and bleed, ICC output intent, Separations Preview, Print with PostScript output | 0 | -| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping and type as text, with live effects left out and symbols and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | +| Exchange files with Illustrator users: open their `.ai`/EPS/PDF, hand back a file they can open | partial (doesn't block alpha) | `.ai`, EPS and PDF open as editable art, but real files can get the wrong layer structure (#951); Save As `.ai` is PDF-compatible with an editing copy of the art (layers, groups, paths, colors, gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols, with live effects kept only on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports ([file-format-parity.md](file-format-parity.md)), other objects' effects other than geometry effects left out, and live objects written as their art; #868), or legacy version 7, which keeps layers, groups and type as text, one artboard per file, and waits for users' testing. EPS legacy mesh corners, vector opacity masks and raster print-page comparison now preserve editable art under synthetic regressions. This is the beta main-format gap ([gaps.md](gaps.md) G9) | 10–20 (for beta) | Hindi interface follow-up (2026-11-06): the catalog and zero/one plural rule are implemented; native wording review, layout checks and a web Devanagari font remain. [Notes](localization-hi.md). diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 36fca35a4..31be4a0eb 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -202,7 +202,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | -| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects and with symbols and live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | | Guides, grids, smart guides, snapping, rulers | 3 | 80% | "intersect" smart guides for a moved selection's anchors (drawn points and dragged anchors snap to intersections, #1658), distance readouts while drawing | 4–7 | estimated (lowered from 85%: target moved); new ruler-guide Alt orientation implemented; video rulers (View › Rulers › Show Video Rulers, #2849) done, score unchanged | @@ -283,8 +283,9 @@ The discounts, with the evidence: ones are about structure, not missing art: layers flattened or multiplied (#951: 109 layers instead of 14; #868), guides lost (#779), slower opening (#758), live effects, brushes and symbols as their drawn look (#637). VectorCraft's PDF-compatible `.ai` carries an editing copy of the art (layers, groups, paths, colors, - gradients, patterns, images, transparency, opacity masks, clipping and type as text), with live effects left out - and symbols and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one + gradients, patterns, images, transparency, opacity masks, clipping, type as text and symbols), with live effects kept only + on paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, other objects' effects other than geometry + effects left out, and live objects written as their art; the legacy `.ai` of version 7 it also writes keeps layers, groups and type as text (one artboard per file, no transparency) but hasn't been tested by users yet (#868). That blocks beta (the main-format rule) for people who hand files back to Illustrator users, but it doesn't stop someone opening and reworking their own archive. - **Interaction fidelity (×0.9).** Pen, Direct Selection, smart guides and snapping are deep @@ -518,6 +519,7 @@ since 2026-10-01. ## Evidence: what ships today, by area - 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo. +- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - **Status-bar pointer readout (2026-10-18):** uses the same effective ruler origin as the position fields, then formats the relative X/Y in General units. The absolute pointer position, document, selection, Undo/Redo and saved state are preserved. Three `chrome::tests::status_coordinates_*` painted-text regressions cover zero/positive/negative origins, unit conversion, global/artboard mode, active artboard, reset and missing pointer/document. Original headless status-bar captures were reviewed before and after the change. Scores and hours are unchanged. @@ -546,6 +548,7 @@ since 2026-10-01. - **Global Edit Options in the Control bar:** while Select › Start Global Edit can run, the Control bar shows a Global Edit Options dropdown that opens the same options as the Properties panel's (Match: Appearance, Size; On Artboards; Include Objects On Canvas; Result(s)); both set the same options. The dropdown is off while Global Edit is on; it sits after Recolor Artwork, ahead of the X/Y/W/H fields ([Edit similar objects globally](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-similar-objects.html)). Regression test: `the_control_bar_shows_the_global_edit_options` in `crates/ui-egui/src/chrome/tests.rs`. - **Global Edit's enabled check on large paths:** the check that decides whether Select › Start Global Edit can run on the selected path, compound path or symbol instance (which the Control bar makes every frame) measures the box of its anchors and handles instead of building and normalizing a copy of every anchor, with the same answer. On a 200,000-anchor path the check takes 0.7 ms instead of 2.2 to 3.0 ms. Regression tests: `support_*` in `crates/engine/src/cmd/globaledit.rs`. - **Control bar's inline X, Y, W and H fields fit the bar:** the Control bar shows the inline X, Y, W and H fields only when all four, their labels as drawn in the current language and the W/H link fit inside the bar's margin; otherwise it shows the Transform link alone. This is VectorCraft's own layout rule. Regression test: `the_control_bar_shows_the_inline_transform_fields_only_when_they_fit` in `crates/ui-egui/src/chrome/tests.rs`. +- **Control bar docks to the top or the bottom:** the Control bar's panel menu, at the bar's right end, has Dock to Top and Dock to Bottom, the edge in use checked, and dragging the bar's gripper docks it at the top or the bottom edge of the window, whichever the pointer is released nearer, with a band along that edge while it moves (`window.control.dock {edge}`), as the help page describes ([Control panel overview](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/control-panel-overview.html)). Keeping the edge with the workspace, with older workspaces docking the bar at the top, is VectorCraft's own rule. Not done: dragging the gripper away from both edges to float the bar. Regression tests: `the_control_bar_panel_menu_docks_it_to_the_top_or_the_bottom`, `dragging_the_control_bar_gripper_docks_it_at_the_nearer_edge`, `the_control_bar_dock_command_takes_top_or_bottom` and `menus_stop_above_a_control_bar_docked_at_the_bottom` in `crates/ui-egui/src/chrome/tests.rs`, `saved_workspaces_restore_the_control_bar_edge` in `crates/ui-egui/src/workspaces.rs`. - **Pattern-only backquote drags:** With Selection move, bounding-box scale or rotate, or the Rotate, Reflect, Scale or Shear tool, backquote held at mouse release applies the drag matrix to selected pattern paint while object geometry stays in place. The key may be pressed after mouse press; if released before the mouse button, the drag commits as an ordinary transform. Objects without pattern paint stay still; stroke-only patterns and patterned objects in mixed selections take the same matrix. Regression tests: `backquote_move_uses_release_state_and_one_commit` and `backquote_box_scale_and_rotate_use_pattern_matrix_at_release` in `select/tests.rs`, `backquote_drag_previews_and_commits_pattern_only_for_each_transform_tool` in `xform/transform.rs`, `selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art` and `selection_backquote_drag_changes_stroke_only_pattern` in `tests/pattern.rs`, `egui_backtick_drag_uses_key_state_at_release` in `canvas/tests.rs`, and `synthetic_pointer_backquote_keeps_solid_object_in_place` in `control/tests.rs`. Scores and hours unchanged. - **Existing-guide Escape cancellation (G1):** Escape clears a pending guide press or drag through Selection and Direct/Group Selection, cancelling a begun preview. Eight app-input regressions cover both axes, move/copy, temporary selection-tool borrowing from idle Pen, preselected-guide document/interaction-start selection and Undo/Redo/saved-state preservation, later on-canvas motion/release, Shift-press cancellation, and ordinary-release/Enter/idle controls. Historical focused checks on `8ab574b7` recorded five expected RED failures with three passing controls, then all eight GREEN tests and the Direct Selection Escape control passed. All nine also passed in current full CI. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure. The original 1280×960 headless app capture from `b076025a` was reviewed for one vertical Selection guide after Escape; source review found its scene unchanged on `a0671777`. Scores and hours unchanged. - **Escape in more tool drags:** Escape during an Anchor Point drag, a Mesh tool drag, a Direct Selection drag of a mesh point, a corner widget or the brackets of type on a path, or a Shape Builder drag cancels it: the art goes back to how the press found it, no undo step is added, the release does nothing, and the tool stays chosen. Escape during a Measure tool drag brings back the measurement before it. Regression tests: `escape_cancels_*` in `draw2/anchor/tests.rs`, `meshedit.rs`, `direct/tests.rs`, `builder/tests.rs` and `xform/measure.rs`, and `escape_cancels_an_anchor_point_mesh_shape_builder_or_measure_drag` in `tests/toolescape.rs`. @@ -592,6 +595,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Rulers (#1212):** drag the intersection to preview and set a document zero point, or double-click it to reset to the active artboard's top-left. Transform, Properties and Control X/Y fields measure from it and convert entered positions back to absolute command coordinates. The origin survives every native format version and compression; geometry, guides and patterns stay in place. Public reference: [Use rulers](https://helpx.adobe.com/ca/illustrator/desktop/measure-and-align/grids-and-guides/use-rulers.html). - **Global and artboard rulers:** View › Rulers › Change to Global / Artboard Rulers (Cmd+Alt+R, `view.globalRulers`). A new document has artboard rulers: the rulers and the Transform, Properties, Control and Info X/Y count from the active artboard's top-left corner, or the zero point dragged out for that artboard, which moves with it; with global rulers one document zero point serves every artboard. Guide readouts and Shift ruler-tick snapping follow the same zero point. The mode is saved with the document; files from before it open with global rulers, as they measured. Regression tests: `engine/tests/it/ruler_origin.rs`, `ui-egui/src/tests/ruler_origin.rs`, `tools/src/rulerguide.rs`. Public reference: [About rulers](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/about-rulers.html). - **Video display options (#2849):** Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio, and View › Rulers › Show Video Rulers ([Set video display options for artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)). Each artboard has its own (`artboard.setProps`/`artboard.new` `showVideoSafeAreas`, `videoRulerPixelAspectRatio` 0.1–10, default 1), set in one undo step and written to the native format only when on or not 1, so other documents save byte for byte as before. The help page names the safe areas without numbers; our choice is [EBU R 95](https://tech.ebu.ch/docs/r/r095.pdf)'s (note 5): the canvas outlines the action-safe area 3.5% and the title-safe (graphics-safe) area 5% in from each edge, 1 point wide and turned with the view; exports and print leave them out. Show Video Rulers (`view.videoRulers`) shows the rulers counting the active artboard in pixels from its top-left corner, whatever the ruler origin: a pixel is as many points wide as the ratio and one point tall, so a 720 × 480 pt artboard at 0.9 reads 800 × 480. Hide Video Rulers returns them to the General unit; Shift ruler-tick snapping of new guides keeps the General unit. Regression tests: `tests/panelcmds.rs` (`artboard_video_display_options_*`, `artboard_display_marks_stay_out_of_exports`), `tests/docsetup.rs` (`an_artboard_shows_its_video_safe_areas`), `canvas.rs` (`video_rulers_count_the_artboard_in_pixels_of_its_aspect_ratio`), `tests/ruler_origin.rs` (`show_video_rulers_counts_the_active_artboard_in_pixels`), `dialogs/tests.rs` (`artboard_options_show_video_display_options`). +- **Fade Region Outside Artboard:** Artboard Options' Global checkboxes Fade Region Outside Artboard and Update While Dragging ([user guide, Artboard options](https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf)) are application preferences (`fadeRegionOutsideArtboard`, `artboardUpdateWhileDragging`), both on by default. While the Artboard tool is active the canvas draws everything outside the artboards, the pasteboard and the art on it, with each color channel at 60% of its value (VectorCraft's own amount), or only outside the box around all the artboards when crossing artboards would leave more than 4,096 rectangles in view; with Update While Dragging off it draws as usual during an artboard move or resize. Presentation Mode, exports and print do not change. Regression tests: `canvas::tests` (`the_artboard_tool_fades_the_pasteboard`, `presentation_mode_draws_no_fade`, `the_fade_darkens_art_on_the_pasteboard_and_not_on_the_artboard`, `outside_rects_cover_the_bounds_minus_the_boards`, `outside_rects_of_a_row_of_boards_stay_few`, `the_fade_of_crossing_boards_stays_bounded`, `update_while_dragging_keeps_or_drops_the_fade_during_an_artboard_drag`, `the_pasteboard_fade_leaves_exports_unchanged`), `dialogs/tests.rs` (`artboard_options_global_checkboxes_set_the_preferences`, `artboard_options_error_leaves_the_global_preferences_unchanged`). - **Saved views (G7):** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html): up to 25 views per document, saved with it, renamed or deleted in Edit Views, chosen from the bottom of the View menu). Each view keeps the zoom, centre and rotation; the View menu lists all 25 (`view.goto1`–`view.goto25`), and Edit Views… lists them to rename or delete, applied together as one undo step. A file's views are checked when it opens (at most 25, unique names up to 255 characters, a finite centre and a positive zoom). Tests: `saved_views_round_trip_and_from_a_file_are_tidied` (`crates/format/tests/prop_format.rs`), `crates/engine/src/cmd/views.rs`, `crates/ui-egui/src/dialogs/edit_views.rs` and `every_saved_view_is_listed_at_the_bottom_of_the_view_menu` (`crates/ui-egui/src/menus.rs`). - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. - **Window › Arrange › Tile (G7):** Tile (`window.arrange.tile`) shows every open document at once, edge to edge in the document area ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)), one pane each in a grid of as many columns as the square root of the number of documents, rounded up, each fitted to its pane. The current document's pane is the canvas; the other panes draw their documents with their artboards, guides and selected objects, and a press in one makes its document the current one. Consolidate All Windows (`window.arrange.consolidateAllWindows`) shows one document under its tab again. The grid and when the two items are enabled (Tile with two documents or more, Consolidate All Windows while they are tiled) are VectorCraft's own. Tests: `crates/ui-egui/src/canvas/panes/tests.rs`. @@ -599,6 +603,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Dragging a document tab out and a document window back (G7):** a document tab dragged more than 24 pt above or below the tab strip floats its document in a window under the pointer, and a document window dragged onto the tab strip goes back under the tabs ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)). A window inside the app's window is dragged by its title bar; an OS window by the document's tab at its top, and the window follows the pointer. The 24 pt distance and the tab at the top of an OS window are VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Reordering document tabs (G7, #5663):** a document tab dragged along the tab strip moves its document among the others ([public source](https://helpx.adobe.com/illustrator/using/customizing-workspace.html)); a line marks the gap it goes in. The current document stays current, each document keeps its own zoom, centre and artboard, the views Window › New Window opened move with their document, and Window › Next/Previous Document follow the new order. `document.move` (`{from?, to}`) does the same from MCP, the control channel and recordings. `crates/ui-egui/src/tests/tab_reorder.rs`, `crates/engine/src/tests/document_move.rs`. - **Preferences › User Interface › Open Documents As Tabs (G7, #394):** checked by default, documents open under tabs in one document window ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/workspace-overview.html)). With the box cleared, each document opened or created afterwards opens in a floating window of its own, at the next cascade place, as Float in Window floats it; documents already open stay where they are, and checking the box again leaves floating documents in their windows. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. +- **Preferences search:** a Search Preferences field above the category list has the keyboard focus when the dialog opens, and Cmd/Ctrl+F gives it back; typing lists the settings it finds, and choosing one shows its category, as the [help page](https://helpx.adobe.com/illustrator/desktop/get-started/preferences-and-settings/set-app-preferences.html) describes. VectorCraft's own rules: the field matches a setting's label, section or category, ignoring case, in the interface language or in English; the list replaces the categories while the field has text and shows every match, or "No settings found"; the chosen setting's row is highlighted and scrolled into view; Escape in the field with text clears it, and Escape in the empty field closes the dialog. The dialog's `__search` field (`ui.dialog.set`) holds the text. Tests: `crates/ui-egui/src/prefs_dialog/tests.rs`. - **Window › New Window (G7, #4116):** New Window (`window.newWindow`) opens another view of the current document ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html) describes showing one document in several windows at once), with its own zoom, center, rotation, artboard and Outline or Preview, as a tab of its own; an edit in one view shows in every view. Tile, Cascade, Float in Window, Float All in Windows and Consolidate All Windows arrange each view as they arrange a document, and Next and Previous Document step through the views. Closing a view leaves the document open; closing its last view closes the document, asking to save it first. The view's number after the name in its tab ("Name:2", while the document has more than one view) is VectorCraft's own. Tests: `crates/ui-egui/src/doc_windows/tests.rs`. - **Document Setup modal handoff:** Document Setup → Edit Artboards applies valid settings, closes the dialog and activates the Artboard tool. Pointer-click tests cover successful handoff, invalid settings, OK, Cancel and Undo in `dialogs/document_setup.rs`. @@ -657,6 +662,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. + - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). Nothing runs scripts yet. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - **UI:** Illustrator 2026 layout restyled to measured values: - Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs. - Hint bar, contextual task bar, 19 dock panels with ≡ menus. @@ -693,9 +699,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The status bar's messages for documents opened or saved with notes and for discarded or restored recovered documents, and the Global Edit options' Results line, do the same (`open_save_and_recovery_messages_take_the_plural_form` in `crates/ui-egui/src/i18n/tests.rs`); the warnings for linked images and placed documents output as their previews and the errors for a page past a placed file's last page or artboard name their counts in the singular or the plural in English. - Edit › Undo and Edit › Redo, in the in-window and native menus, name the step in the interface language as the History panel lists it (`panels::history::step_name`), so when the catalog has no plain row for a step named after an effect, such as Feather, the menu shows the translation of the effect's menu row without its ellipsis (`undo_and_redo_name_the_step_as_the_history_panel_does`). - The History panel translates the undo step names the engine, the tools and the shell write as literals: the untranslated report (`untranslated_report`) lists any a complete catalog lacks, and a step whose catalog row exists only with an ellipsis (such as an effect's menu name) shows that row without the ellipsis. - - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. Not done: a new document from the selected state. + - The History panel menu has Step Backward and Step Forward, which run Edit › Undo and Edit › Redo and are disabled when there is no step to undo or redo, and Set History Limit…, which opens Preferences at Performance, where History States is set. - The History panel menu's Clear History (`edit.clearHistory`) removes every step to undo and to redo and leaves the document as it is; it is not an undo step itself, and it is disabled when there is no step to clear. + - The New Document from State button at the bottom of the History panel (`edit.newDocumentFromState`) makes a new document, which becomes active, from the state the panel has selected ([public source](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/undo-edits-and-manage-design-history.html)). VectorCraft's own rules: the new document takes the next Untitled-N name, is marked unsaved, has nothing selected and has no steps to undo or redo, and the source document and its steps stay as they are. - The Transform panel menu's Transform Object Only, Transform Pattern Only and Transform Both (`transformPanelPatterns`) choose whether the fields, flips and menu flips of the Transform panel and the Properties panel's Transform section move the object and leave its pattern tiles, move the tiles alone, or both (until a choice they follow General › Transform Pattern Tiles, as before); Use Registration Point for Symbol (`useRegistrationPointForSymbol`) measures and transforms one selected symbol instance from its symbol's registration point, where the instance's flips, rotations and shears put it ([Transform panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/transform-panel-overview.html); `panels::transform::tests::the_menu_chooses_whether_the_object_its_pattern_or_both_transform`, `use_registration_point_for_symbol_measures_an_instance_from_its_registration_point`, `use_registration_point_for_symbol_follows_a_transformed_instance`, `tests_prefs::transform_panel_patterns_defaults_to_following_the_general_preference`). + - Object › Transform › Reset Bounding Box squares the box of every object inside the selected ones as well: the children of groups, compound paths, clip groups, blends, envelopes, repeats and intertwines, and the art of linked opacity masks. Ungroup and the release commands then bring back boxes square to the page (VectorCraft's own behavior; `bboxrotate::reset_bounding_box_squares_the_objects_inside_a_group`, `reset_bounding_box_squares_the_turned_members_of_a_square_group`, `reset_bounding_box_then_release_clipping_mask` and the other `reset_bounding_box_then_*` tests, `reset_bounding_box_leaves_the_art_of_an_unlinked_opacity_mask`). - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). @@ -712,6 +720,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Delete Symbol asks first** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), [symbol libraries](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/create-or-import-symbol-libraries.html), [Alt-click Delete](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)): the Symbols panel's Delete Symbol button and menu item ask "Delete the selected symbol?" with Yes and No for a symbol no instance uses. For a symbol with placed instances they ask with Expand Instances (the instances become ordinary art), Delete Instances and Cancel; either Instances button deletes the symbol in one undo step. Alt-click on the button deletes an unused symbol without asking and still asks about one with instances. A question left open while another document became active deletes nothing. `symbol.delete` itself does not ask. Tests: `panels::symbols::tests::delete_symbol_asks_first_and_alt_click_deletes_an_unused_symbol_at_once`, `the_in_use_question_expands_or_deletes_the_instances`, `a_stale_delete_symbol_question_deletes_nothing`, `the_delete_symbol_questions_show_their_buttons`. - **Symbols › Select All Unused** ([Symbols panel](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/symbols-panel-options.html), #4918): the Symbols panel menu's Select All Unused shows the symbols nothing places selected (`symbol.unused`): instances in the art (opacity masks included), graph designs, pattern tiles and brush art count, and so do instances in a used symbol's art. Delete Symbol, the button or the menu item, then asks "Delete the selected symbols?" once and deletes those still unused in one undo step (`symbol.delete {names}`); Alt-click on the button deletes them without asking, and a click on a symbol picks just it again. Tests: `tests::brushsym::symbol_unused_lists_the_symbols_nothing_places_and_delete_takes_several`, `panels::symbols::tests::select_all_unused_picks_the_unused_symbols_and_delete_deletes_them`, `dialogs::symbol_delete::tests::several_symbols_get_the_plural_question`. - **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`. + - **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_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. + - **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_leaves_unchanged_instances_and_refuses_bad_ids`, `panels::symbols::tests::reset_transformation_is_enabled_for_a_transformed_instance_and_resets_it`. - **Expand of dimensions:** Object › Expand, Expand Appearance, Create Outlines and Outline Stroke turn a dimension whose art they change into a plain group of that art, which stays as it is through later moves, scales and edits of what it measured; undo gives the live dimension back (`crates/engine/tests/it/dimensions.rs`; VectorCraft's own rule; estimated, score unchanged). - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Calligraphic Nibs, Pattern Borders, Scatter Shapes) 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. A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection), or the view commands on empty canvas. @@ -729,7 +740,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Preferences audit (2026-10-30, #394): every Preferences dialog option is read by shipped code, by its field or its key, except User Interface › Open Documents As Tabs, Units › East Asian Type, Hyphenation › Default Language, Plug-ins & Scratch Disks › Primary and Secondary, and Clipboard Handling › Legacy vector clipboard and its mode, which are stored only; [gaps.md G12](gaps.md#g12-preferences-and-panels-that-dont-act-yet) says what each waits on. - With Performance › Real-time Drawing and Editing off, the bounding box moves with a Selection-tool move drag and the object moves when the button is released ([help](https://web.archive.org/web/20220324053041id_/https://helpx.adobe.com/illustrator/using/real-time-editing.html); `real_time_drawing_off_moves_the_bounding_box_with_a_move_drag`). - Performance › Real-time Drawing and Editing off defers a drag's edits to its release, but a ruler guide dragged out of a ruler or moved still shows as it is dragged, where only its position label showed (#4646; `a_guide_dragged_out_of_a_ruler_shows_while_dragged`, `real_time_drawing_off_still_shows_a_dragged_guide`). - - With Real-time Drawing and Editing off, a dragged selection whose outlines have more than 100,000 points shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`). + - With Real-time Drawing and Editing off, a dragged selection whose outline points and evaluated objects come to more than 100,000 shows its moved bounds instead, as the selection at rest does (#525, #2460; `real_time_drawing_off_draws_the_bounds_of_a_selection_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_group_too_big_to_outline`, `real_time_drawing_off_draws_the_bounds_of_a_repeat_with_too_many_objects`). + - With Real-time Drawing and Editing off, a move or handle drag of a group, blend, envelope, repeat or intertwine draws the outline of each of its objects instead of one union of them, as VectorCraft's own rule (`real_time_drawing_off_outlines_a_dragged_group_member_by_member`, `real_time_drawing_off_outlines_a_resized_group_member_by_member`, `real_time_drawing_off_outlines_a_dragged_blend_step_by_step`, `real_time_drawing_off_outlines_a_dragged_envelope_object_by_object`). A group with a Pathfinder effect draws the area the effect makes, a gradient mesh draws its mesh lines, and a symbol instance draws its bounds (`real_time_drawing_off_outlines_a_dragged_pathfinder_group_by_its_area`, `real_time_drawing_off_outlines_each_mesh_of_a_dragged_repeat_once`, `real_time_drawing_off_outlines_a_dragged_symbol_instance_by_its_bounds`). - Dialogs move by dragging their heading (the band down from the top edge, as a title bar), stay on screen and keep where they were moved for the session; the form dialogs' choices are dropdowns (Offset Path's Joins, Average's and Shear's and Reflect's axis), and Offset Path lists Offset, Joins, Miter Limit and previews (#423). - Dialogs open with their first field focused and its value selected: type a value and press Enter (Move, Rotate, Scale…). A dialog opened from a menu takes the keyboard focus every time it opens, not only the first (#1367), and one whose first field starts greyed out (Blend Options on Smooth Color, Layers Panel Options) gives the focus to that field once it is enabled (#1395). Number fields in panels, the Control bar and dialogs (preferences too) step with Up/Down, or with the mouse wheel over the focused field (#485: a notch a step; the panel under it then stays put, and an unfocused field leaves the wheel to scrolling) (Shift: ten, Ctrl/Cmd: a tenth of their unit), applied at once. - While Alt (Option on macOS) is held, the Flare Tool Options' Cancel button reads Reset, which puts every option back to the Flare tool's default and leaves the dialog open (`ui.dialogReset`). @@ -738,10 +750,10 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The Pattern Options panel's Name field and the name fields that a double-click opens in the Layers, Artboards and Asset Export panels take Shift+Enter (Shift+Return) the same way: it applies the name and keeps the field active with the name selected. - Scrubby labels (#400, beyond Illustrator, as in Photoshop): dragging a numeric field's label left or right (the ↔ cursor shows over it) steps the value one unit every 2 points (Shift: ten, Ctrl/Cmd: a tenth; counts stay whole), in the field's unit and within its limits, applied live as the art follows; the whole drag is one undo step and Escape puts the value back. A click on the label focuses the field; the text box itself never scrubs, and a field with no label doesn't. Text labels before a field (Transform, Properties, Control bar, Stroke, Transparency, form rows in dialogs) and the Character and Paragraph panels' icons act as labels, and so do the links that label a field (the Control bar's Stroke: and Opacity:, the Properties panel's Stroke), which a click still follows to their panel (#4088). Preferences › General › Scrub Numeric Fields by Dragging (on by default) turns it off. - In the Transform panel, the Properties panel's Transform section and the Control bar, Alt+Enter (Option+Return) applies a value to a copy of the selection and selects the copy, in one undo step. With anchor points selected with Direct Selection it applies the value to them, as Enter does. Ctrl/Cmd+Enter in W or H applies the value and scales the selection proportionally with the link off. With the Artboard tool, it scales the active artboard proportionally. A percentage typed alone in W or H (`50%`) is that share of the size the field shows, and the link and Ctrl/Cmd+Enter keep the proportions as with a length (#2401). - - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. + - The Control bar's underlined Transform link opens the Transform panel in a popover under it (reference point, X/Y/W/H, Rotate and Shear with their icons and angle presets, flips, shape properties, Scale Corners and Scale Strokes & Effects), as its Stroke link does the Stroke panel; X/Y/W/H also show inline while the bar has room. The reference point locator is nine squares, the outer eight joined around the box. As VectorCraft's own keyboard rule, the locator is one tab stop in the Control bar popover, the Transform panel, the Properties panel and the dialogs, the Arrange Tools dialogs included. While it has the focus it shows an outline, and ←, →, ↑ and ↓ move the reference point one square, stopping at the edges (`the_reference_point_grid_is_one_tab_stop`, `arrow_keys_move_the_reference_point`, `the_reference_point_locator_is_one_tab_stop_and_arrows_move_it`). - In isolation mode the Control bar has an Exit Isolation Mode button (`object.exitIsolation`), except while a pattern is edited. Outside isolation mode, with one group, compound path, path, image, mesh or placed file selected, it has an Isolate Selected Object button (`object.isolate`), which also isolates a path inside a group that Direct Selection selected. - While an opacity mask is edited, Exit Isolation Mode (Esc, the isolation bar's back arrow, the Control bar's button, and the Layers panel menu's and canvas context menu's Exit Isolation Mode) leaves mask editing as Stop Editing Opacity Mask does, in one undo step: the mask keeps the edits, and the masked object is selected. While a pattern is edited, the Exit Isolation Mode item of the Layers panel menu and of the canvas context menu saves the pattern and leaves pattern editing, as Done does. - - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). + - Tab shows and hides the panels (in the Type tool it types a tab) without moving the keyboard focus onto a field, so the next shortcut or letter still works; in dialogs and panel fields it moves between fields, one press each — a numeric field's scrub label is not a tab stop of its own, and the Transform panel's, Properties panel's and Control bar's W/H link comes after the fields it sits between (`one_tab_moves_from_a_field_to_the_next`). A field's up/down spinner, the chevron that opens its presets (Stroke Weight, font size, Rotate, Export for Screens' scale) and the Transparency row's opacity slider chevron are not tab stops either. Alt+↓ with no other modifier in a field with presets opens them with the keyboard focus on the chosen one, instead of stepping the field down; Enter picks the focused preset without pressing a dialog's OK, and after a pick or Escape the field has the keyboard focus again (`tab_goes_from_field_to_field_past_the_spinner_and_the_preset_chevron`, `tab_goes_from_the_opacity_field_past_its_slider_chevron`, `alt_down_opens_a_fields_presets_for_the_keyboard`, `escape_closes_a_fields_presets_and_returns_to_the_field`). - Shift+Tab (`window.panels`) shows and hides the dock and the floating panels and leaves the toolbar and the Control bar as they are; hiding the panels with Tab or Shift+Tab also closes a panel popped out of the icon column. - The second Tab (`window.dock`) shows again only the bars the first Tab hid, after a restart too: a Control bar that was off stays off, a toolbar hidden with Window › Tools stays hidden, and panels shown with Shift+Tab in between stay shown. - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. @@ -793,6 +805,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Edit › Keyboard Shortcuts lists each of the Align panel's Align Objects, Distribute Objects and Distribute Spacing buttons under Align, with no default shortcut, as the reference app's [release notes](https://web.archive.org/web/20261006170350/https://helpx.adobe.com/illustrator/desktop/new-features/release-notes.html) describe for its Align and Distribute commands. A shortcut set there runs what a click on the button runs, with the Align To choice, the key object and the Distribute Spacing value (`align.horizontalLeft` and the other `align.*` UI commands). The command palette lists `select.key` as Cancel Key Object, and a pick cancels the key object (#541). Agent selection commands validate every object id before changing the selection and report the resulting count and ids (#784). + Commands that act on `id` or else the selection, the highlighted layer row or a new object (`css.export`, `object.isolate`, `layer.locate`, `layer.releaseToLayers`, the graphic, character and paragraph style commands, `image.info`, the `links.*` and `shaper.*` commands that take an `id`, opacity mask editing and viewing, `symbol.edit`, the blend spine commands and `path.curvature`) refuse an `id` that is not an object id (`"abc"`, `"5"`, `true`) and change nothing; `effect.apply` with the effect given as `id` still applies it to the selection. Agents read the key object: `select.key` returns it, and `document.inspect` and the `ui.pointer` and `pointer_gesture` replies report it as `key`. `select.key` with an id that isn't a selected top-level object returns an error and leaves the key unchanged. The Select menu's Same commands, its Object commands that select art, Next Object Above and Below, saved selections and the Magic Wand (#2655) select only objects that Select All selects and objects inside them: nothing hidden or locked, nothing in a hidden or locked group, layer or sublayer, nothing on a template layer, and in isolation mode nothing outside the isolated group or layer. In isolation mode Select All, All on Active Artboard and Inverse select nothing while the isolated group or layer, or a group or layer it is in, is hidden, locked or a template layer. Select ▸ Object ▸ Bristle Brush Strokes selects the objects whose topmost stroke has a bristle brush, whatever the brush is named, and leaves out objects whose brush is of another type, also when its name has "Bristle" in it. @@ -856,13 +869,14 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur While a drag rotates the view, a dial at the center of the canvas shows the angle. - **Editing type:** double-clicking type with a selection tool (Selection or Direct Selection) switches to the Type tool with the caret where it was clicked; pressing Escape finishes editing and switches back to the selection tool used before double-clicking it (when in isolation mode, the first Escape exits text editing while preserving isolation mode, and a second Escape exits isolation). While a Type tool edits type, a click that lands on no type ends the editing without making new type, and that tool stays active; the next click makes new type (#2871, #2854). + As VectorCraft's own rule, Cmd+Enter (Ctrl+Enter on Windows and Linux) finishes editing as Escape does and keeps the typed text; a command bound to that chord runs instead. Inside a ligature (one glyph for several characters, such as fi), the Type tool's caret, clicks, arrow keys and selection stop between its characters, at the font's ligature carets ([OpenType GDEF ligature caret list](https://learn.microsoft.com/en-us/typography/opentype/spec/gdef)) or, when the font does not list one valid caret for each stop, at the glyph's width divided equally. Contour-point carets use the font's unhinted outline at its default variation location; pixel-size device adjustments are not applied. Duplicate Artboards, the Artboard tool's Alt-drag and Paste give the new artboard an id that no other artboard has, also when an artboard's id is the largest possible value (#1718). Deleting the copy or the original then leaves the other's guides. - **Touch Type:** a click with the Touch Type tool (Shift+T) selects one character and shows a box around it. Dragging the box or its lower-left handle moves the character. The upper-left handle scales it vertically, the lower-right handle scales it horizontally, the upper-right handle scales it proportionally, and the upper-middle handle rotates it. Each drag is one undo step that sets the character's kerning, baseline shift, scales or rotation, and the type stays editable (`text.touchType`; behavior reference: [Touch Type tool](https://web.archive.org/web/20261007185617/https://helpx.adobe.com/illustrator/using/tool-techniques/touch-type-tool.html), [Add text with the Type, Area Type, and Touch Type tools](https://web.archive.org/web/20260926095922/https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/add-text-illustrator-text-tools.html)). Composition's leading-space reduction stays along the line when a character rotates. An opening bracket trimmed by Line-end Punctuation Half Width Mojikumi keeps that placement under Character Rotation; its turning point follows the composition adjustment, and removing the rotation keeps the trim. Scores and estimates are unchanged. A character rotated near or at a quarter turn follows a Touch Type drag across its line as well as along it: baseline shift moves a character up from its line at any rotation, and the character turns about its shifted pen position. Right-to-left characters can also move along their line when a preceding logical character is on that line. Positive `text.touchType` `dx` follows reading order, toward the left in horizontal right-to-left text; the first logical character still moves only up and down. These direction rules are VectorCraft's choices; the public Touch Type page describes moving a character without specifying this case. Scores and estimates are unchanged. - - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. + - **Temporary tools:** Cmd with any other tool drags with the selection tool used last (Selection, Direct Selection or Group Selection). Held Space pans and Cmd+Space zooms (Cmd+Alt+Space out) whatever the tool. A single-key tool shortcut held for 0.25 s or more, or held while its tool is used, chooses that tool only until the key is let go, and the tool active before comes back (after a drag in progress ends); a quicker press chooses the tool for good. This is VectorCraft's own rule and its own threshold, and General › Hold a Tool Shortcut to Use the Tool Temporarily (`holdToolKeys`, on by default) turns it off. Space held while the Zoom tool drags a marquee moves the marquee and keeps its size ([Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)). When Space is let go, the drag sizes the marquee again from where it moved to. A Space+Shift drag rotates the view whatever the tool, as a Rotate View tool drag does, and Shift does not round the angle to 15°. - **Swatches panel clicks:** Ctrl/Cmd-clicking New Swatch makes a spot color, and Ctrl/Cmd+Shift-clicking it makes a global process color. @@ -907,6 +921,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Panel Options › Locate Selected Objects (beyond Illustrator, off by default): when the last selected object changes on the canvas or by a command, the panel opens the layers and groups around it and scrolls to its row, as Locate Object does, without highlighting the row; when Show Layers Only, a search or the filter leaves the object without a row, the panel opens no rows for it and scrolls as with the option off. - **Drawing aids:** Smart Guides and snapping (moving, with the Selection, Group Selection or Direct Selection tool, and resizing by the bounding-box handles with the Selection or Free Transform tool, Shift and Alt too: the dragged corner or side lands on the other objects' anchors and centres ("anchor", "center") or lines up with their edges and centres (a line and "align"); with Smart Guides › Spacing Guides, a moved selection that lines up with nothing along an axis lands as far from its neighbour as two other objects of its row or column are apart, or halfway between its two neighbours, the equal gaps marked; with View › Snap to Grid on, a moved selection lands on the grid instead, by whichever of its edges is nearest a gridline on each axis (the center does not snap), #740, #1816; the Rotate, Scale, Shear and Reflect tools drag from the anchor they grab and snap the pointer the same way, so a corner dragged onto another object's anchor scales exactly onto it, #482; anchors dragged with Direct Selection: the one pressed on lands on other anchors, its own path's that stay put among them, on path segments or in line with them, the others following, Shift keeps 45° steps, and with Smart Guides off Snap to Point pulls it onto anchors; the Artboard tool snaps artboards, and their bleed, to other artboards, bleeds and art, the grid or whole pixels while moving, copying and resizing them, and the Transform panel shows and edits the active artboard's X/Y/W/H; drawn points, #506: hovering shows where the press goes, the point lands on the other objects' anchors, centres and paths ("anchor", "center", "path") or lines up with their anchors, edges and centres ("align"), the Pen's on the path being drawn too, near a construction guide through the anchor before it on that guide (Preferences › Smart Guides › Construction Guides and Angles), and with Shift kept to its 45° step or a square's diagonal it slides along that way into line; the targets are gathered once per document state, from the art in the window, and the anchors, centres and lines near the pointer are found by bisection, so hovering never rescans the document), guides dragged out of the rulers (#451: they snap as moved guides do, Shift to the ruler's ticks; with the Artboard tool they are artboard guides, which run across their artboard only and move, copy and delete with it, `guide.add {artboard}`) that the Selection, Direct Selection and Group Selection tools then pick and drag (#414: the selected guides highlight and move together, Alt copies them, Shift snaps the dragged one to the ticks, otherwise it snaps to pixels, the grid or the edges, centres (so the sides' midpoints) and anchors of the art and the artboards, or with Smart Guides off Snap to Point pulls it onto anchors; Shift-clicked they join the selected art, and Shift-added art keeps them, so guides and art are selected together in either order, Delete then deleting both in one step; the arrow keys nudge them, Delete deletes them, dragging one back onto its ruler deletes it, Lock Guides and Hide Guides leave them alone; `guide.list`, `guide.select`, `guide.move` and `guide.remove` reach agents), a right-click on either ruler or the origin box to pick the document's units (the current one checked; `document.setUnits`, one undo step, #528), and Draw Normal / Behind / Inside modes. Smart Guides: a point drawn with the Pen or a shape tool, and an anchor dragged with Direct Selection, snaps to where paths and ruler guides cross, labelled "intersect" (#1658, part of #1602; a moved selection's own anchors don't). A move that Shift keeps level, upright or diagonal stays on that line when it snaps, with the Selection, Group Selection or Direct Selection tool. It slides along the line until the selection lands on the grid or on whole pixels, lines up with the other objects or is spaced evenly with them, or until the line passes an anchor or crosses a ruler guide near the point the selection was grabbed by. A level or upright move snaps along its own axis only. + Snap to Last Location: with Smart Guides on and Preferences › Smart Guides › Snap to Last Location on (off by default, VectorCraft's own choice), a selection moved by dragging with the Selection, Group Selection or Direct Selection tool, Shift too, goes back to where the move began while it is within Snapping Tolerance of it, labeled "last location"; an Alt copy is not pulled back onto its original, and with View › Snap to Grid or Snap to Pixel on the move snaps to the grid or to whole pixels instead (`prefs.set` key `snapToLastLocation`). The public [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) page lists the option as snapping objects back to their previous position; the distance, the label, the copy rule and the grid and pixel rule are VectorCraft's own. Tests: `select::tests::snap_to_last_location_puts_a_move_back_where_it_began`, `select::tests::snap_to_pixel_wins_over_snap_to_last_location`, `direct::tests::snap_to_last_location_puts_a_path_moved_by_its_anchor_back`, `direct::tests::snap_to_pixel_wins_over_snap_to_last_location_for_a_path_moved_by_its_anchor`, `tests_prefs::snap_to_last_location_puts_a_dragged_square_back`. Distance Guides: with Smart Guides on (View › Smart Guides and Preferences › Smart Guides › Invoke Distance Guides, on by default), something selected and the Selection tool, holding Alt/Option over another object shows the distances between the selection's bounds and that object's: on an axis on which they lie apart, one line across the gap between their facing edges; on one on which they overlap, the offsets of their left and right (top and bottom) edges, each line running across the middle of where they overlap, with a dashed line out to the other object's edge when it passes beside it. Over an empty part of an artboard it shows the selection's distances from the artboard's left, right, top and bottom edges. Each line has ticks at its ends and its length in a pill of the smart guide colour, in the General unit; letting Alt go, pressing (a pan or zoom press too), moving off them, leaving the canvas or a floating panel, menu or dialog over the pointer takes them away, and with the pointer still they follow what lies under it as the view scrolls, zooms or turns, as the document, the tab or the selection changes, and when the Selection tool is switched to. Behaviour from the public [Use Distance Guides for accurate placement](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/use-distance-guides-for-accurate-placement.html) and [Smart Guides options](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/smart-guides-options.html) pages, which describe hovering only: nothing shows while moving or nudging. Tests: `select::tests::alt_over_*`, `select::tests::distance_guides_need_alt_a_selection_and_smart_guides`, `tests_prefs::invoke_distance_guides_measures_the_selection_to_the_object_under_alt`, `canvas::tests::alt_over_another_object_draws_the_distance_guide_label`, `canvas::tests::distance_guides_go_when_the_hover_ends`, `canvas::tests::distance_guides_follow_what_lies_under_a_still_pointer`. A move pressed on a path's stroke, or within 6 px of its outline away from its anchors and center, is held by the nearest point of that path, with the Selection, Group Selection or Direct Selection tool, and a straight segment Direct Selection drags is held by its own nearest point. That point is what snaps, so the path lands exactly on a ruler guide (with "align") instead of the pressed spot landing there and the path a few pixels beside it, an offset that showed only zoomed far in (#3701). A press in the middle of a fill still snaps the pressed spot: the public [Align objects with guides](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/align-graphic-objects-with-guides.html) page has Snap to Point align the point under the pointer, which is placed exactly where the object should meet the guide. A bounding-box handle dragged with the Selection or Free Transform tool snaps what it moves as a dragged point snaps (#2054). With View › Snap to Grid on, it snaps to the nearest gridline, and with Snap to Pixel on, it snaps to whole pixels. With Smart Guides off and Snap to Point on, it snaps to the other objects' anchors and the ruler guides within the Snap to Point distance. With Smart Guides and Snap to Point both on, it lines up with the ruler guides as well as the other objects. Shift still keeps the proportions, and Alt still scales about the center. @@ -951,6 +966,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Geometry and effects:** - **Open-path anchor Delete:** Delete/Backspace removes selected anchors and their adjoining segments, leaving drawable runs as separate open subpaths of the original object. Surviving curves and appearance stay intact; one Undo restores the document and selection, and a second Delete removes the selected remainder. Explicit Remove Anchor Points still reconnects/refits. Four core regressions, one full-frame Delete/Backspace regression and three existing removal/segment controls pass, covering these distinctions, multiple deletions, transformed geometry and mixed whole-object selection. Behaviour references: the legacy public [Illustrator reference guide, Add or delete an anchor point](https://helpx.adobe.com/content/dam/help/en/pdf/illustrator_reference.pdf) distinguishes keyboard deletion from dedicated removal; the current [Add or remove anchor points](https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/add-or-remove-anchor-points.html) describes the dedicated tools. Other G11 gaps and scores/hours remain unchanged. - Pathfinder (10 exact curve booleans; single-result shape operations retain source names and SVG IDs, #1293), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. With one group selected alone, the Pathfinder panel's buttons work on its members (among other selected objects a group still acts as one shape), and a greyed-out button says what it needs (#1450; behaviour reference: [combine objects using Pathfinder effects](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/combine-objects-using-pathfinder-effects.html); one plain path selected alone stays disabled, which the documentation doesn't settle). Split Into Grid takes the number, height or width, gutter and total of the rows and of the columns, Add Guides and Preview, and several selected paths become one grid painted as the topmost one (#1462, `object.path.splitIntoGrid`; behaviour reference: [divide or split objects](https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/divide-or-split-objects.html); a typed height or width keeps the gutter and changes the total, our choice). Object › Path › Remove Anchor Points (`path.removeAnchors`) removes the direct-selected anchors without opening their paths (the Delete key still deletes them with their segments): the neighbours keep their handle directions and their facing handles are refitted so one cubic follows the two old segments, and straight sides stay straight. The Delete Anchor Point tool uses the same refit. The Control bar, the contextual task bar, the Properties quick actions and the canvas context menu offer it when anchors are selected. With anchors selected, the Control bar and the Properties panel also convert them to corner or smooth (`path.convertAnchors`), connect end points (Join) and cut the path at them (`path.cutAtAnchors`, also on the task bar and the context menu): a closed path opens there and an open one becomes one path per piece, each cut leaving one of its two coincident anchors selected so a drag pulls the path apart. In the Pathfinder panel a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled (#1642, #1635). + Outline Stroke and Unite remove the overlaps of a long, low path with the sweep turned along it: when the estimated number of segments the sweep holds at once is under two thirds as large for the path turned a quarter turn (paths of 2,048 segments or more), the overlaps are removed on the turned path and the result is turned back, which moves no point by rounding. The lookups of the input curve that a piece of the result lies on, and of the input anchors that are kept, can search along y instead of x: once lookups with more than 64 candidates along x have counted 16 for each input curve or anchor, a lookup searches along y when it finds fewer candidates there (under a quarter as many for curves), and where that has rarely happened it measures along y in only one lookup of 32. On one open path of 200,000 anchors in rows of 1,000, Outline Stroke takes 17.3 s of processor time instead of 31.3 s and Unite 2.9 s instead of 6.5 s in a release build. A turned result has as many subpaths as the result without the turn, and the area where the two differ is no larger than the refit precision times the outline's length (`crates/pathops/src/sweep_axis.rs` tests). - Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged. - Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect. - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). @@ -1012,6 +1028,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Select › Same › Fill Color, Stroke Color, Fill & Stroke, Stroke Weight, Opacity, Blending Mode, Appearance, Shape, Graphic Style, Appearance Attribute and Symbol Instance compare the other objects with the first selected object that can be selected, of any kind: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated (except new art while it stays selected), or outside the isolated group is passed over. This is VectorCraft's own behavior. Select › Object › All on Same Layers takes the layers only of the selected objects that can be selected: an object that is hidden or locked, on a template layer, on a locked artboard while nothing is isolated, or outside the isolated group adds no layer. This is VectorCraft's own behavior. Object › Flatten Transparency takes a frame of threaded text out of the thread in the same way when Convert All Text to Outlines outlines it or when the frame becomes flat regions or an image, and the other frames keep the text they hold. + Object › Flatten Transparency, the Flattener Preview, and EPS, PDF and print output that flatten transparency stop building a group's flat regions at the first region past the raster/vector balance's limit (10^(balance/20), rounded down) and rasterize the whole group without building the remaining regions. Building every region gives the same flattened art and the same files. With Clip Complex Regions on, a group builds every region and its image is clipped to them. New Symbol and Redefine Symbol on a frame of threaded text take it out of the thread with the text it shows, which the symbol's art holds, and the other frames keep the text they hold. When the last frame goes into the symbol, the text it has no room for goes after the text of the last frame that stays. When every frame of a thread goes into the symbol, the copy of the last frame keeps all its text. With Optical Margin Alignment on, or Roman Hanging Punctuation on in the first paragraph, the glyphs at the ends of left-to-right type on a path hang outside its brackets, measured along the path. The type hangs at the start bracket when its line is placed there, hangs at the end bracket when the line is placed there, and hangs at both when it is centered. Glyphs that hang past the end bracket do not make the type overflow, and a hang stops at an open path's end. SVG export writes such type as outlines. The frames of threaded text share one Optical Margin Alignment setting: turning it on or off in one frame sets it in every frame, threading frames gives the thread its first frame's setting, and frames taken out of a thread keep it. @@ -1036,6 +1053,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a drag on the Tabs panel's ruler or a value typed in its X field moves a tab stop past another stop, the moved stop stays selected. The Tabs panel menu's Clear All Tabs (`text.tabs.clear`), Delete Tab and Repeat Tab (`text.tabs.set`) can be chosen while the panel's ruler shows tab stops. Delete Tab removes the stop selected on the ruler. Repeat Tab replaces the stops after the selected stop with up to 20 copies of it. The copies are spaced by the selected stop's distance from the stop before it, or from the paragraph's left indent when it is the first stop, and no copy is placed at 2,000 pt or beyond. Clear All Tabs removes every stop, so the default stops every ½ inch apply again. - **Interface languages, vertical type:** Preferences ▸ User Interface (or VectorCraft ▸ Language on macOS) picks Automatic (the system locale) or a registered language, persisted as `interfaceLanguage`. Translations come from per-language catalogs (`i18n/*.tsv`): Traditional and Simplified Chinese, Japanese, German (every `de-*` locale), Spanish (neutral international Spanish, for every `es-*` locale), French (every `fr-*` locale), Italian (every `it-*` locale), Russian (every `ru-*` locale) and Ukrainian (every `uk-*` locale), Catalan (every `ca-*` locale) and Polish (every `pl-*` locale; AI-drafted, native review pending) cover the whole interface, Czech every menu label; Czech, German, Greek, Spanish, French, Hungarian, Italian, Japanese, Korean, Vietnamese, Polish, Brazilian Portuguese, Russian, Simplified Chinese, Traditional Chinese, Swedish, Ukrainian and Finnish also translate status-bar and error messages as they are shown (`@msg` templates, every message literal tested; the messages themselves stay English for agents); untranslated text stays English. Japanese and Chinese glyphs come from the craft-fonts build input (release builds) or the installed fonts, Czech, German, Spanish, French, Italian, Russian and Ukrainian ones from the bundled UI fonts. The web app follows the browser's languages (or `?lang=`). The Basic toolbar's Type button holds Vertical Type in its flyout. Vertical Type, Vertical Area Type and Vertical Type on a Path create vertical text (columns right to left, upright CJK glyphs and marks with `vert` alternates, Latin and longer numbers on their side, two- and three-digit numbers set as tate-chu-yoko in one em of the column (kept whole when justified), upright glyphs centred on the column (tracked or justified too), point type's anchor on the first column's centre line); kinsoku applies to horizontal and vertical Japanese (Paragraph panel › Kinsoku Set, `text.setFormat {kinsoku}`: Hard, the default, keeps closing marks, iteration marks, the prolonged sound mark and small kana off the line start and opening brackets off its end; Soft lets 々, ー and small kana start a line as JLREQ's level 3 rules do; None turns it off), and justified Japanese lines spread their room between characters (not inside Latin words); Paragraph panel › Mojikumi Set (shown with Preferences › Type › Show East Asian Options, `text.setFormat {mojikumi}`): None or Line-end Punctuation Half Width (new type's default, as in Illustrator; older documents and imported text keep None) shares one half-em space between consecutive punctuation, sets a closing mark ending a line half width and an opening bracket starting a line flush with its start (a paragraph's first line at its first-line indent, JIS X 4051's principle in JLREQ 3.1.5), and puts a quarter em between Japanese and Latin letters or digits; Paragraph panel menu › Top-to-Top / Bottom-to-Bottom Leading measures leading from em box top to top (area type's first line touches the frame) or from baseline to baseline (new type in a Japanese interface starts top to top, with em box centre character alignment, #432); Paragraph panel menu › Burasagari None / Regular / Force (`text.setFormat {burasagari: "none"|"standard"|"forced"}`) hangs an East Asian comma or full stop (、。,.、。) ending an area type line outside it, Regular (new type's default) when it doesn't fit and Force always (the rest of the line then fills the measure; closing brackets and Latin punctuation don't hang); Character panel menu › Character Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {charAlign}`) lines characters smaller than the largest on their line up on the Roman baseline, the em box top/right, centre or bottom/left, or the ICF (ideographic character face) top/right or bottom/left, from the font's BASE table or, without one, the average ink box of some ideographs and kana; Proportional Metrics (the same menu; `{proportionalMetrics}`) sets full-width glyphs on the font's proportional widths (OpenType `palt`) in horizontal type and upright glyphs on its proportional heights (`vpal`, shaped top to bottom; fonts without vertical metrics keep their cells) in vertical type, and Line-end Punctuation Half Width takes nothing more off the punctuation they re-spaced; Type ▸ Type Orientation switches existing text; caret, selection, hit testing and arrow keys follow the writing direction. Vertical text exports to PDF as real text and to SVG as outlines. The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). + Interface text in characters the interface's fonts lack, drawn in a bundled Japanese, Georgian or Devanagari face or in an installed font that covers them, sits on the baseline of the Latin text beside it, monospace text included (#5816; `crates/ui-egui/src/ui_fonts.rs`, `crates/ui-egui/src/theme.rs`). Character panel menu › Standard Vertical Roman Alignment (with the East Asian options; `text.setFormat` or `text.setRangeStyle {standardVerticalRoman}`, off by default) stands the selected Latin letters, digits and other half-width characters upright one below the other in vertical type instead of rotating them ([formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); spaces keep their width, tate-chu-yoko blocks stay across the column, and Character Style Options gives it its own checkbox beside Character Alignment. Character panel menu › Tate-chu-yoko Settings… (with the East Asian options; `ui.tateChuYokoSettings`, `text.setFormat` or `text.setRangeStyle {tcyUpDown, tcyLeftRight}`, in points, 0 by default) moves a marked tate-chu-yoko block in vertical type up by a positive Up/Down and right by a positive Left/Right ([format East Asian characters in vertical text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/format-east-asian-characters-in-vertical-text.html), [formatting Asian characters](https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html)); the block moves as one piece with its first character's values and keeps its one em down the column, automatic digit blocks do not move, Reset Panel sets both back to 0, and character styles and the native file keep them. A file whose character styles hold a Tate-chu-yoko Up/Down or Left/Right offset past the coordinate limit (`MAX_COORD`) either way opens with the nearer end, as the type's own offsets do; an offset within it stays as saved. @@ -1058,6 +1076,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Image Trace, Preview:** the Image Trace panel's Preview checkbox, before Trace, is on by default, and a setting change traces the selected Image Trace object again at once. Off, changes wait in the panel until Trace applies them as one undo step, or until Preview is turned on again. Changes stay with the object they were made for while another object is selected. When Preview is turned on with that object not selected, the object is traced with its changes when it is selected again. Preview is kept with the UI preferences, and Reset to Default leaves it. The `imageTrace.preview` command turns it on or off. - **Image Trace small contours:** closed fits too small to form an editable subpath retain their positioned outline, including holes. Source-generated island, hole/winding and stepped-region regressions cover Corners and Noise; defaults, parity scores and estimates are unchanged. - **Image Trace, custom presets:** Manage Presets next to the Image Trace panel's Preset dropdown saves the panel's settings as a new preset under a name (Save as New Preset…) and renames or deletes the saved preset shown; built-in presets don't change. Saved presets are kept with the preferences and follow the built-in ones in the panel, Control bar and Properties preset lists. `imageTrace.presets.save` and `imageTrace.presets.delete` save, rename and delete them, `imageTrace.presets` lists them, and `imageTrace.make {preset}` traces with one by name. +- **Image Trace, preset buttons:** six buttons above the Image Trace panel's Preset dropdown choose a preset as the dropdown does, for the options the [help page](https://helpx.adobe.com/illustrator/desktop/manage-objects/traces-mockups-symbols/image-trace-panel-options.html) lists before Preset. The presets each button chooses are VectorCraft's own: Auto Color chooses Auto Color, High Color chooses High Fidelity Photo, Low Color chooses Low Fidelity Photo, Grayscale chooses Shades of Gray, Black and White chooses Black and White Logo, and Outline chooses Outline. With Preview on, a click traces the selected Image Trace object again as one undo step; with nothing traced selected, it changes the panel's settings only. The button of the panel's preset shows as selected. - **Create Strokes baseline (2026-10-17, measured):** the ignored centre-lines class covers 23 exact code-drawn scenes in aa and hard variants, including junctions, crossings, fills meeting strokes and varying widths. [Baseline and metric definitions](image-trace-quality.md#centre-lines). Topology is recovered from emitted geometry; width-profile metrics remain unavailable. No behaviour change or score increase. - **Image Trace, collapsed loops keep few segments:** a loop whose fit collapses to one cubic keeps the fewest segments that hold its area and course, so a higher Paths gives no fewer segments; a code-drawn Paths sweep in `crates/trace` (see [quality notes](image-trace-quality.md#collapsed-loops-keep-few-segments)). - **Image Trace, stacked layers only under their own areas:** with Method Overlapping, a layer reaches under the colors above it only within the connected areas that hold its own color, so a shape that sits apart from a color is no longer written again, at the same bounds, in that color, and the edge of that color's layer no longer shows through that shape's anti-aliased rim. *Measured* by `crates/trace/tests/quality` on each preset's own inputs: High Fidelity Photo 121 paths to 69.6 with ΔE 1.74 to 1.72, Low Fidelity Photo 37.1 paths to 26.1. Code-drawn disc, square and line tests in `crates/trace`. @@ -1073,6 +1092,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Advanced art:** live Blends (steps/distance/smooth colour, editable spine, anchor-targeted Blend tool, faithful interpolation, knockout), Envelope Distort (warp/mesh/top object, Reset, full Envelope Options, mesh handles, Edit Contents; type, images, symbols, gradients, patterns and appearance distorted everywhere), Liquify tools with options and pen pressure, Puppet Warp with rotating pins, Perspective Grid with presets, movable planes and projected type (choosing a perspective tool shows the grid; Hide Grid hides it with the tool still chosen), Gradient Mesh, Shape Builder (stroke-only open paths and lines cut the regions they cross and enclose regions of their own, and regions they bound merge without seams where the lines cross at rounded coordinates (#893); filled open paths count as closed; erase mode while Alt/Option is held: a minus pointer, red highlights of what will go, a click or drag deletes the regions touched, the pieces of open lines and the edges (outline pieces between the points where paths meet, opening their paths there) it passes over, one undo step, `shapeBuilder.merge {erase}` and `shapeBuilder.regions` edges over MCP), Live Paint (every path an edge, open lines included), Image Trace (12 presets, colour layers traced in parallel; a trace past two million anchor points is refused with advice rather than running out of memory, an image over 64 megapixels is traced scaled down to that size (aspect kept, paths at the image's own size) and one over 192 megapixels is refused, and a selection too large to outline shows its bounds (#525); Object › Image Trace › Make and Make and Expand trace at once with the Default preset, #543; with an image selected the Control bar and the Properties panel show Image Trace, whose arrow traces with any preset, Mask (`object.maskImage`: a clipping path on the image's outline, selected so its handles crop), Crop Image (a crop box on the image, starting on its part over the artboard, dragged by its handles or inside it, the outside dimmed, with X/Y/W/H, Apply and Cancel in the Control bar, Enter and Escape, #734; `object.cropImage {rect}` for agents, `{trim: true}` cuts an image to its pixels that aren't fully transparent, pixel for pixel) and Embed or Unembed in place of Fill and Stroke; with an Image Trace object selected, its Preset (choosing another traces again), View, the Image Trace panel and Expand, plus Release in Properties; the five View modes (Tracing Result, with Outlines, Outlines, Outlines with Source Image, Source Image) redraw a trace on screen without tracing again, are saved with it and `imageTrace.setView` sets them, while exports and Expand keep the result; the panel's Advanced section is a header that stays open or closed as it was left (part of #641); Color mode's Palette: Limited, Full Tone (as many colours as the tones need, Less … More), Automatic (flat art with its own colours, else Full Tone; a heuristic) and Document Library (the most used colours of the document's swatches or a swatch library, filled with the swatch colours as they are), part of #641; Advanced › Create: Fills and/or Strokes, where lines up to the Stroke width trace as stroked centre lines (distance transform, thinning, a skeleton graph with spurs pruned, curve fitting; one width per line; without Fills, wider areas become stroked outlines), part of #641), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). A single selected mirror repeat shows a prominent axis with a center move handle and two rotation handles. Drag the line or center to change spacing, or an end to change angle; Shift snaps rotation to 45°. The original artwork stays in place, the reflected copy previews live, Escape cancels and each drag takes one undo step through `object.repeat.options`. Controls follow zoom/view rotation and hide with edges or the bounding box. Engine and headless canvas regressions passed for gestures, history and visibility; native move, rotation and Escape screenshots were reviewed. [Public mirror-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-mirror-repeat-options.html). A single selected grid repeat shows a handle below the grid for rows, one right of it for columns and one past its bottom-right corner for both, and sliders between its first two columns and rows for the horizontal and vertical spacing; the source keeps its size, each drag is one undo step and Escape cancels it ([public grid-repeat reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html), #2691). Object › Expand turns radial, grid and mirror repeats into groups of their instances (#1765, #1756). Choosing the Blend tool again while it is active (its toolbar button, W or `tool.select`) finishes the blend: the object clicked first is dropped and the next click no longer adds to the blend just made, so the click after it starts a new blend ([Blending objects](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)). Window › Blend exposes easing Type, Ramp and independent color Shift through `object.blend.options`. + Smooth Color counts the steps of each pair of keys from their color change, or from the distance between their visible bounding-box edges when their colors match or they hold gradients or patterns ([Blend Options](https://web.archive.org/web/20241217212108/https://helpx.adobe.com/illustrator/using/blending-objects.html)); the step counts, their caps and the colors compared are VectorCraft's own. Envelope Options › Anti-Alias off draws an envelope's distorted images without smoothing, and Preserve Shape Using › Transparency gives each piece of a distorted image an opacity mask in its shape instead of a clipping mask ([Envelope panel options](http://web.archive.org/web/20260513013456/https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/envelope-panel-options.html)). The four polynomial distributions are original approximations of the public description, not measured numeric parity. See [Blend panel overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blend-panel-overview.html). @@ -1099,6 +1119,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **CSS swatch libraries:** fractional linear and radial gradient stop positions are preserved, so nearby stops such as 12.5% and 12.890625% remain distinct instead of collapsing to 13%. Reference: [CSS Images 3 color stops](https://www.w3.org/TR/css-images-3/#color-stop-syntax). The original synthetic regression is `palette_io::tests::css_preserves_fractional_gradient_stop_positions` in `crates/color/src/palette_io.rs`. Scores and hours are unchanged. - **Swatches:** names follow the colour model and are unique; Swatch Options (process/spot, Global, Gray/RGB/HSB/CMYK/Web, live preview); editing a global or spot swatch recolours every linked fill, stroke and text run in one undo step; multi-select and delete with confirmation; New Swatch and New Color Group dialogs (a group from the selected artwork's colours); swatches in colour groups convert, separate and count like any other. The Swatches panel selects ranges and whole groups, has a find field, and drags swatches to reorder, regroup or paint art; Add Used Colors, Select All Unused, Merge, Ungroup and Sort by Kind; a CMYK document keeps applied colours in CMYK. Swatch libraries: nine generated colour libraries, five gradient libraries and a pattern library (Geometric Patterns, whose swatches bring their patterns) in a library panel, saved as `.vcswatches`, `.gpl`, swatch exchange (`.ase`) or CSS, and loaded from `.vcswatches`, `.gpl` and `.ase` files (`.ase` both ways: RGB, CMYK, Lab and Gray; global, spot and process; groups) and from color books (`.acb`, read only: the RGB, CMYK or Lab colors of a book such as a Pantone book a user owns, named with its prefix and suffix, spot unless it says process, #831); `swatch.library.copyToUser` copies a loaded library into the User Defined folder without replacing a file, and a file with the same extension and bytes as a User Defined library opens as that library. Tints of global and spot colours ("Name 40%") stay linked and separate as a percentage of their plate. Spot colours can be defined in Lab (Spot Colors options). A built-in [Registration] swatch prints on every plate. Editing, renaming, deleting or merging a global swatch also changes the colors linked to it in graph designs, the art of brushes and the tile that pattern editing's Cancel puts back, in the same undo step as the rest of the art; Default Swatches with replace unlinks them there too. + Add Used Colors (`swatch.addUsedColors`) and Select All Unused (`swatch.unused`) look colors, swatch names, links, tints and gradients up in hash sets instead of searching every swatch and color for each color or linked paint (New Color Group from artwork too), and New Swatch with several colors (`swatch.new`), New Color Group (`swatch.newGroup`) and Edit Color Group (`swatch.editGroup`) name their new swatches from one set of the names already taken. The new swatches keep their names, order and links, and Select All Unused lists the same swatches. Evidence: `add_used_colors_adds_each_new_color_once_with_free_names`, `add_used_colors_global_links_every_paint_of_a_new_color`, `unused_matches_links_tints_colors_gradients_and_patterns_exactly`, `new_swatches_from_colors_are_numbered_after_names_already_taken`, `new_color_group_numbers_its_colors_after_the_group_and_its_moved_swatches`, `new_color_group_from_artwork_lists_each_color_once_with_free_names` and `edit_color_group_numbers_extra_colors_after_the_names_it_keeps` in `crates/engine/src/tests/swatchcmds.rs`; `free_swatch_names_match_naming_each_swatch_as_it_is_added` and `free_swatch_names_match_new_swatch_names_as_swatches_are_added` in `crates/doc/src/swatches.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. SVG writes the fill color through a coverage mask (or a colorized PNG in Tiny 1.2); EPS writes binary coverage as an image mask in the color's ink, and a grayscale image as ink samples scaled by coverage, with white pixels left out at Level 3 and gray midtones written over white. The prompt for deleting several swatches and groups uses the interface language's plural form for the count. Evidence: `deleting_several_swatches_uses_each_count_form` in `crates/ui-egui/src/panels/swatches/tests.rs`. - **Colorized images (#1466):** a fill color applied to a selected grayscale or 1-bit image (`paint.setFill`) colorizes it with black, white, a process color or a spot color, and refuses a process color when the image has an alpha channel ([Convert color modes](https://web.archive.org/web/20251220223850/https://helpx.adobe.com/illustrator/desktop/manage-colors/select-and-adjust-colors/convert-color-modes.html), "Colorize Grayscale or 1-bit images"); black and white are accepted there too. The canvas draws each pixel in the color as far as the pixel is dark, with white pixels transparent; PDF export writes the image in the color (a spot color as its ink, Registration in every ink) with the image as its soft mask; Separations Preview and printed separations show it on its color's plates; native files keep the color. EPS and SVG write the image's own pixels. @@ -1106,6 +1127,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Swatch library and Recolor Artwork parameters:** the `swatch.library.*` commands, `swatch.resetDefaults`, the graphic style library save, load and add, the `recolor.*` commands and `color.harmony`'s `limitTo` refuse parameters of the wrong form, such as `names: "Grays"`, `limitTo: 5` or `includeImages: "false"`. Errors name the parameter, except that a `limitTo` that is not a string, such as `5`, gets an unknown-library error. A `null` counts as left out except for `recolor.apply`'s `model`, which must be a string when given. - **Colour:** a Color Picker (colour field, H/S/B/R/G/B channel slider, HSB/RGB/Lab/CMYK fields, hex, web-only snapping, out-of-gamut correction); the Color panel shows a selected colour in its own model and, with nothing selected, the new-art defaults in the mode picked last, which it keeps between sessions; Alt-click paints the other proxy; proxies look through groups and show "?" for mixed paint; Invert, Complement, Apply Last Color (`,`) and Apply Last Gradient (`.`) keep the colour model; every paint command feeds the recent colours. The Color Guide has 21 harmony rules, Steps and Variation options and Limit to Library. Adjust Color Balance (its Global mode shifts tints) and Saturate preview live; Edit Colors and Recolor also reach meshes, images and patterns. Recolor Artwork has colour reduction, five recolour methods, presets, Limit to Library, a colour-wheel Edit tab and Edit Color Group; a local Color Themes panel saves five-colour themes. Fill/Stroke chips open swatch and mixer popovers (the Properties panel has the Control bar's Fill and Stroke chips, Stroke link and weight spinner, with nothing selected too), panels have Illustrator's Window menu keys (Align Shift+F7, Transform Shift+F8, Pathfinder Cmd+Shift+F9, Layers F7, Character Cmd+T…; every panel can be given one in Edit › Keyboard Shortcuts), and Eyedropper Options pick up and apply chosen attributes (Alt and Shift+Alt, image pixels). An ICC CMYK profile converts straight into the RGB working space (not through sRGB, so a wider space such as Wide Gamut RGB keeps colours sRGB can't hold) with the colour settings' black-point compensation, on the canvas and in RGB exports alike (the built-in Generic CMYK compensates both ways, so RGB black separated to a rich black displays as black again). Fills and strokes can overprint (Attributes panel, Overprint Black options), and CMYK documents blend in inks on screen, in PDF and when flattening. Document Color Mode converts the art, symbols, pattern tiles and swatches through the colour settings: Gray stays Gray, RGB greys separate into four-colour greys and a rich black as Illustrator does, or go on the black plate only with `grays: "black"` (also for `document.open {colorMode}`). 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`. 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`). @@ -1150,6 +1172,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Brushes panel menu's Select All Unused selects the brushes that are not used in the document, as the [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page (archived copy) describes. A brush counts as used when a stroke paints with it in the art (opacity masks included), in symbols, graph designs, pattern tiles or graphic styles, or in the art of a used brush (`brush.unused`). A rename in Brush Options and Delete Brush change every stroke that paints with the brush: in the layers and opacity masks, symbols, graph designs, pattern tiles, graphic styles and the art of other brushes. A rename gives those strokes the new name, and Delete Brush makes them plain strokes, in one undo step. In Pattern Brush Options, a menu below the Outer Corner Tile and the Inner Corner Tile sets each to the brush's own corner art (Original), None, or a tile made from the side tile: Auto-Centered stretches one side tile around the corner, Auto-Between bends a side tile on each side into the corner's diagonal, Auto-Sliced cuts a side tile on each side along the diagonal, and Auto-Overlap runs a side tile on each side past the corner. Auto-Sliced masks the tiles at the diagonal, so their outlines stay as drawn. Brushes saved without these choices draw their corners as before (`brush.options` `outer_source`, `inner_source`). Evidence: `tests::pattern_auto_corners_fill_each_corner_of_a_square`, `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), `dialogs::brush_options::tests::pattern_options_set_each_corner_tile_to_original_none_or_an_automatic_tile` (vectorcraft-ui-egui). + + Pattern brush corner tiles are square to the side tiles at a right-angle corner and turn 90° from one corner to the next, as the [Using Illustrator CS5](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf) help (About patterns) describes. That document's steps for making corner tiles build both the outer and the inner tile as the corner joining a side tile on its right to one below it, and the brush reads corner art that way, with Flip Along and Flip Across or without (#6110). At corners that are not right angles, the tile is turned halfway between the two sides, which is VectorCraft's own rule. Evidence: `tests::pattern_corner_tiles_are_square_to_the_sides`, `tests::pattern_corner_tiles_sit_on_the_corners_diagonal` (vectorcraft-brush). - The [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) page describes up to five Pattern brush tiles: sides, inner corner, outer corner, beginning and end. The archived [Brushes](https://web.archive.org/web/20241230205225/https://helpx.adobe.com/illustrator/using/brushes.html) page says to add pattern tiles to the Swatches panel and select a swatch from the scroll list for each tile. Pattern Brush Options offers document pattern swatches in all five tile menus, including swatches named like built-in choices. The brush copies each swatch's whole art and tile frame. Side requires art; explicit None on a corner prevents fallback to the other corner's art. After a swatch is deleted, the brush keeps drawing its copied art and shows it as Original. The cited pages do not describe copying the whole art, requiring Side, suppressing corner fallback after explicit None, or showing deleted swatch art as Original. `brush.options` accepts swatch names and an object form for names that match built-in choices, and rejects unknown patterns before editing. Evidence: `tests::brushsym::pattern_tile_choices_copy_whole_swatch_art_and_reject_invalid_side` and `tests::brushsym::pattern_tile_object_selects_reserved_swatch_names` (vectorcraft-engine), `tests::pattern_corner_source_none_leaves_that_kind_of_corner_empty` (vectorcraft-brush), and `dialogs::brush_options::tests::pattern_tile_menus_use_document_patterns_and_side_none_keeps_dialog_open`, `dialogs::brush_options::tests::pattern_options_borrowed_swatch_corner_keeps_its_frame`, and `dialogs::brush_options::tests::pattern_options_selects_swatches_with_reserved_names` (vectorcraft-ui-egui). - **Appearance:** the Appearance panel's selected row drives every paint, stroke, gradient and transparency edit (`item` on the commands); effects apply to one fill or stroke; per-item Opacity popups, whose blend-mode list and opacity slider stay open inside them (#430; the Control bar's Stroke popover too); Mixed Appearances, Layers target dots and basic-appearance rules. Effects can be reordered, moved between items and copied by dragging; Show All Hidden Attributes; clicking an effect edits it in place; the object thumbnail drags onto art; effects render on type, images, symbols, blends, envelopes, meshes and repeats, and per-item raster effects export. Groups, layers and type have their own fills, strokes and effects (Contents and Characters rows); the Layers panel's target circles target layers, groups and objects; Expand Appearance turns fills, strokes, effects and brushes into objects. Fill and stroke paint (Fill, Stroke, None, Apply Last Color and Apply Last Gradient, also on characters selected with the Type tool), the Stroke panel's options, gradient and freeform point edits, Swap Fill and Stroke, Default Fill and Stroke, the Eyedropper, and the Appearance panel's item edits, Clear Appearance, Reduce to Basic Appearance, Remove Item, item reordering and Move Appearance record no undo step when they change nothing. @@ -1167,6 +1191,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`. In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one. - **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). + - **Load text styles from another document:** the Character Styles and Paragraph Styles panel menus have Load Character Styles… or Load Paragraph Styles… and Load All Styles…, which copy the styles of a document picked in an open dialog, with the swatches and patterns they paint with, in one undo step (`charStyle.load`, `paraStyle.load`, `textStyle.loadAll`); a style the document already has, under its name or a numbered one, is not added again, any other whose name is taken gets a number, and the file's Normal styles are not loaded. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names. - **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize. The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document. A spot colour stays a spot colour: the library keeps its ink name and tint, and using it in any document links the paint to that document's spot swatch of that name, adding it when missing (#5812). @@ -1193,6 +1218,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Duplicate, Copy, Cut and Move Artboard and Rearrange All Artboards take `lockedAndHidden` (default: Move Locked and Hidden Artwork with Artboard) and refuse a value other than true, false or null. When art goes along, the journal records the value, so a replay under the other preference takes the same art. The Layers panel's New Layer (on top), Select Art and Target (Shift-click), Move Rows (the copy), Template, Paste Remembers Layers and Release to Layers (Build) refuse a value other than true, false or null, such as "true", and leave the document, the selection, the highlighted rows and the undo history as they were; New Sublayer refuses such a `top` too. Change Case, Smart Punctuation, Find and Replace, Convert To Area Type, Convert To Point Type, Type on a Path Options, the Type on a Path effects and Fill with Placeholder Text record no undo step when they change nothing. + Find and Replace, Find Next, Change Case, Smart Punctuation, Insert Character and Type on a Path Options refuse a text option other than text or null, such as a replacement of 5, and a switch other than true, false or null, such as "true". Smart Punctuation also refuses a scope other than selection or document, Type on a Path Options a spacing given as text, and Find and Replace `ids` that are not a list of existing object ids. Each leaves the text, the selection and the undo history as they were. The commands that take `keepModel` (fills and strokes, Appearance rows, Live Paint, Shape Builder fills, gradient stops and freeform points, gradient mesh points, type colors, the Symbol Stainer and the swatch commands) refuse a `keepModel` other than true, false or null, such as "true". Toggle Fill/Stroke Focus (`fill`), Invert, Complement, Apply Last Color and Apply Last Gradient (`stroke`), `paint.setFill` and `paint.setStroke` (`focus`, `none`), and `appearance.setItem`, `livePaint.fill`, `livePaint.strokeEdge`, a `shapeBuilder.merge` `fill`, `swatch.new` and `swatch.edit`'s `paint` (`none`) refuse such a value too. Each leaves the document, its undo history and the defaults for new art as they were. The shape tools' commands (Rectangle, Polygon, Star, Flare, Spiral, Arc, Rectangular Grid and Polar Grid), `path.create` (with `anchors` or `d`), the type commands (new type, type in a path, character formats, the byte range a text command takes, Touch Type, font size steps, `text.getRange`, Optical Margin Alignment, System Layout, Fractional Widths, Ruby and Area Type Options), the symbol commands (Place, Delete, the Symbol Sprayer and the symbolism tools), the swatch commands, the Appearance panel's commands, the gradient and freeform gradient commands, Shape Builder, Live Paint and Image Trace refuse an option other than true, false or null and a number given as text, such as "true" or "40", and leave the document and its undo history as they were. - **Fonts:** a font is found by any of its names (family and style in every language of its name table, legacy family/style pairs, PostScript name), so documents from other apps open with the font they name; Find Font and `text.fonts` say for each font whether it was found exactly, by another name, substituted or missing, and how many glyphs are missing. PDF and `.ai` import brings Japanese text back as live type: CID fonts without a ToUnicode map read through the standard CMaps, vertical columns (WMode 1) as vertical type, letter spacing as tracking; a glyph is live type only when the installed font (in the closest style of its family) draws the same glyph, and text in a font that isn't installed comes in as live type shown in the fallback font until the font is installed (exports that draw it — PDF, EPS, EMF/WMF, raster, SVG outlines or embedded fonts — warn that they wrote the fallback font, and Print and Print Preview warn about the type that prints in it; `textAs: outlines` keeps the PDF's embedded glyphs). Two installed versions of one family and style (a font library keeping an old file beside a new one) both load, the first staying the one the family and style resolve to; PDF and `.ai` import tries each version when the glyphs differ, and type set in a version other than that one names it (`CharStyle::font_version`), which layout, the substituted-glyph highlight, Find Font, Package and Document Info follow. Fonts outside the font folders are listed too: those font services such as Adobe Fonts load on Windows, through DirectWrite and, for those only GDI lists, through GDI (#1258), and those apps and font managers register with CoreText on macOS (#579). When a document opens with fonts that aren't available, fonts used only in symbols included, a Missing Fonts dialog lists them after the linked-file questions, with Find Fonts (Type › Find Font on them) and Close. Its Find in Folder… (in Find Font too) searches a folder on several threads, reads only what the font scan reads of font files and skips app packages, the Program Files and ProgramData folders, the system's folders and the app data folders in a home folder; Add Fonts copies the font files the user chooses, suitcase fonts with their resource fork, into VectorCraft's own Fonts folder, which the desktop app and `vectorcraft-cli` read (`text.missingFonts`, `text.findFontFiles`, `text.addFontFiles`). 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). @@ -1231,6 +1257,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur At a precision of 9 decimals or fewer, SVG export writes each number from an integer when the integer rounds it as the decimal formatter does; the formatter still writes values halfway between two written numbers and values too large for the integer. Exporting one path of 200,000 anchors takes a median of 43.4 ms instead of 167.0 ms on Windows 11, and 20,000 rectangles 81.4 ms instead of 141.4 ms, with the same SVG (`crates/svg/src/lib.rs`). SVG import reads each `` and `` as the ellipse VectorCraft draws: four smooth anchors on the axes, starting at the right, with handles of the standard length, so an ellipse exported as `` comes back with the anchors it was written from and draws the same (`circles_and_ellipses_import_as_the_ellipse_drawn` in `crates/svg/tests/it/basic_shapes.rs`, `filled_ellipse_on_the_artboard_edge_survives_visually` in `prop_svg.rs`). SVG import keeps attributes in other applications' XML namespaces (`plotter:pen="2"`) on the objects and layers they belong to, as it keeps their `data-*` attributes: copies, duplicates and `.vectorcraft` files keep them, and SVG export writes them back with each namespace declared once on the root (the rest of #522; `crates/svg/tests/it/foreign_attributes.rs`, `tests/attributes.rs`). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps defines every symbol in its editing copy with its art, registration point and place in the Symbols panel's order, and writes its instances as symbol instances, which VectorCraft's `.ai` reader opens as symbols and instances again (`symbols_and_their_instances_are_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the instances written as their art). + A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes eligible paths, compound paths and groups with live effects with an art style when the writer supports their appearance and effects (not a filled path that uses the even-odd rule, and none on a large canvas), which VectorCraft's `.ai` reader opens as the same appearance with its effects live (`objects_with_live_effects_are_read_back` and `every_effect_setting_written_on_an_object_is_read_back` in `crates/eps/src/ai_editing/tests.rs`; [file-format-parity.md](file-format-parity.md) lists the objects written without one and the effects they lose). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps writes point type, area type and type on a path as type in its editing copy, and objects next to it keep their own transparency (`crates/eps/src/ai_editing/tests.rs`, `cmd::fileio::tests_pdfedit::type_comes_back_from_the_editing_copy`; [file-format-parity.md](file-format-parity.md) lists what is written as outlines). A PDF-compatible `.ai` saved with Include Editing Data for Other Apps keeps the document's variables, data sets and the bindings of objects to variables in its editing copy, in the form the variable library reader above reads, so they come back when the file is opened. Bindings to layers, to type written as outlines, to graphs and to linked images written as other objects, data set values of graphs, and variables, data sets, bindings and values past the writer's limits are left out with a warning (#6024; `crates/eps/src/ai_editing/tests.rs` `variables_data_sets_and_bindings_are_read_back`; `crates/eps/src/ai_editing/writer/variables.rs` lists what is left out). In a `.ai` or EPS opened from its editing data, type has its text object's opacity, blend mode and knockout shape flag, whether it is made from the file's text document or taken from the page, and type made from the text document takes its text object's lock. Where such type has transparency and comes in with other art (the rest of its look, or page type and strokes that go with it), a group of them has it. Where the page draws its type, or the strokes around it, with transparency that is neither its text object's nor its text object's combined with that of the objects around it, the page's type and strokes keep their own instead (`crates/eps/src/import/tests_native.rs`). Where the page draws type of no text object over text objects made from the text document, with their words in any case, that type is theirs as the page draws it and is left out; the page's other type of no text object goes only to text objects whose type the text document can't make, or else is left out with a warning. @@ -1253,8 +1281,11 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur Blends and envelopes in the definitions of a `.ai`'s symbols, patterns and brushes' art count against the same per-file limits on blend layout and envelope checks as the layers' art, so a file with many definitions does not get those limits again for each one (`crates/eps/src/import/ai/read/directives.rs`). A `.ai` or EPS whose editing data has brushes opens with the file's Calligraphic, Scatter, Art and Bristle brushes in the Brushes panel in place of the default ones, in the file's order: the Calligraphic size, roundness and angle with their variation, the Scatter size, spacing, scatter and rotation, the Bristle settings, and the Scatter and Art brushes' art. A brush library `.ai` (brushes and no layers) opens from its PDF part with its brushes, so Other Library lists them. An Art brush in Tints keeps that colorization. Pattern brushes and brushes that can't be read are left out with a note. Paths with a Calligraphic brush, and paths with an Art brush at 100% in Tints that stretches art of solid-filled paths unflipped, open as the path with the brush on its stroke, and other brushed paths still come in as their drawn look (part of #372 and #637; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, a path or type object with Offset Path, Outline Object or Outline Stroke on the whole object opens as itself with those effects live, in their order and shown or hidden; on a fill or stroke of type they stay on that fill or stroke. Offset Path keeps its Offset, Joins and Miter Limit, and Outline Stroke takes its width from the stroke. An Offset Path with a setting missing, out of range or not read keeps the drawn art, as does a path with a Path effect on one fill or stroke (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). + In a `.ai` or EPS opened from its editing data, a Scatter brush whose last 15 numbers start with 1 comes into the Brushes panel in Tints, and each brush art section is read without its frame, an unnamed, unpainted rectangle with no settings of its own at the art's bounds (within 1/1000 pt). A wider frame stays in the art, and an art group with a state or look of its own (hidden, locked, isolated, knockout, an opacity, a blend mode, a name, a mask, a note or an appearance) stays a group (`crates/eps/src/import/ai/tests.rs`). + In a `.ai` opened from its editing data, type with Warp on the object or on one of its fills or strokes opens as type with the effect live when the live warp draws as the art saved with it. When it draws differently, or when the file's limit of checks has no check left for it, it opens as that art, and one note says how many did. Type stays live without a comparison when the saved art paints nothing or draws type of its own, in frames of threaded type, when a text variable binds it, and when its text object doesn't come in as one text object (`crates/eps/src/import/ai/envelope.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, an object with a Tweak that moves nothing or Pucker & Bloat at 0 opens as itself with the effect live. A path, compound path or group whose only effect is Transform without copies, or a Free Distort whose corners make a parallelogram, opens as itself with it live, and so does an unstroked path or compound path with straight segments whose only effect is Twist. A gradient or pattern, an image, type, a clipping group, an opacity mask or effects inside the object, or a stroke with a Transform that scales, rotates or reflects, keep the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/ai/read/ops.rs`). A PDF page whose `UserUnit` (PDF 1.6) is above 1 opens at its size in points, its numbers times the `UserUnit` (one above 75,000 counts as 75,000), and `document.pdfInfo` gives its size and boxes in points too. A `.ai` whose editing data has its artboards in the units of such a page opens from that data at the page's size, with its layers, and when the layers can't be read its pages still take the artboard layout of the data, scaled the same way (part of #1482). + `document.open`, `file.place` and `document.pdfInfo` reject a `cropTo`, `crop`, `password`, `colorMode` or `textAs` that is not a string, such as `"textAs": true`, and `document.pdfInfo` rejects a `thumbnailSize` that is not a number; the error names the param, and nothing opens or is placed. A param left out or given as null keeps its default (`crates/engine/src/cmd/fileio/tests_pdfimport.rs`). When the PDF content of a `.ai` has a page for each artboard, the layers are compared with every page on its artboard (until the pages compared take 8,388,608 pixels at the comparison's scale, about 67 letter-size pages, or number 128): art that only the layers or only a later page draws is handled as art that differs on the first page is, and a text object that a later page doesn't draw is left out unless its layer doesn't print (`crates/eps/src/import/tests_native.rs`). When the editing data of a `.ai` or EPS is for a page of another size than the file's, the warning that its layers weren't read gives both sizes in points, and the scale of the editing data's page to the file's page (1:10 for a tenth of its size) when their sides are in proportion (#2034; `crates/eps/src/import/tests_native.rs`). A `.ai`, or a PDF saved with Illustrator's editing data, whose editing data can't be decompressed opens from its PDF part with a warning that its layers weren't read and why, and an export or Save As to its own path asks first (`acknowledgeLoss`), as for a file whose layers the reader can't read (`crates/eps/src/import/tests_native.rs`, `crates/engine/src/cmd/fileio/tests_aiimport.rs`). @@ -1318,6 +1349,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. + - The Links panel's Relink… opens a Relink dialog whose Replace menu picks which links take the new file: Selected Instances (the selected images, as before), All Instances (every image linked to the same file), Missing Links from This Folder (then every other missing link whose file name is in the new file's folder) and Each Link Separately (a file for each selected link in turn). Each relinks as one undo step (`links.relink {scope}`, `ui.relinkDialog`). Relink to Folder… stays as it is. + - Linked files inside symbols, graph designs, patterns and opacity masks are saved with their paths relative to the document, as linked files in the layers are, so they are found after the document and its files move to another folder together; on open each of those links takes the path where its file was found. Update Links, and opening with Update Links: Automatically, read a modified file again for that art too, and saving a document refreshes its placed copies inside symbols and patterns of the other open documents. Evidence: `links_outside_the_layers_find_their_files_after_the_folder_moves`, `update_links_reads_files_again_inside_symbols_patterns_and_masks`, `saving_a_document_refreshes_its_placed_copies_inside_symbols_and_patterns`. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. @@ -1356,6 +1389,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur When a path object moves without changing shape, the renderer draws its stroke from the outline it made before the move, moved by the same offset. It outlines the stroke again when the path is scaled, rotated or edited, when the stroke changes, and when the move takes the view past the part of a long path it outlined. The pixels are the same. At 1600 × 1200, the frame after a 1 pt nudge takes 123 ms instead of 403 ms with an open path of 200,000 anchors in view (103 ms with nothing changed), and 13 ms instead of 26 ms with a closed path of 50,000 anchors whose stroke is dashed and has a width profile. When the stroke of a path moved this way has a gradient along or across it, the renderer also draws the gradient from the slices it made before the move, moved by the same offset. It makes the slices again when the path is scaled, rotated or edited, and when the stroke or its gradient changes. The pixels are the same. On Windows at 1600 × 1000, the frame after a 1 pt nudge of an open path of 200,000 anchors takes 75 ms instead of 1,237 ms with the gradient along the stroke and 69 ms instead of 1,420 ms with it across, and with an open path of 50,000 anchors 22 ms instead of 313 ms and 21 ms instead of 352 ms (medians of five interleaved runs). The renderer keeps the path of a path or compound path of 1,024 anchors or more by object, with the bounds of its segments and of its miter spikes in runs of 128 segments. When anchors move and the subpaths and anchor counts stay the same, the next frame changes only the segments that start or end at a moved anchor and measures again only the runs that hold them or the segments with length before them, instead of making the whole path again and measuring it whole for culling. The pixels and the cull bounds are the same. On Windows at 1600 × 1200, the frame after one anchor moves takes 8.2 ms instead of 30.1 ms with an open path of 200,000 anchors, 4.3 ms instead of 10.4 ms with a closed path of 50,000 anchors, and 6.6 ms instead of 11.9 ms with the filled closed path of 50,000 anchors and a dashed stroke (medians of five runs of `anchor_bench`). + When only anchors of a path or compound path move, the canvas draws again only around the segments that start or end at a moved anchor, grown by the farthest its strokes reach, instead of the whole object before and after. This holds while every fill and stroke is a solid color, and the strokes have no dashes, width profile, arrowheads or brush, and the object has no effects or opacity mask; any other object still counts whole. On a loaded Windows machine at 1600 × 1200, single-threaded in a development build, the frame after one anchor of a filled closed path moves takes 16.6 million CPU cycles instead of 56.4 million with 50,000 anchors and 65.6 million instead of 180.0 million with 200,000 anchors (medians of 15 edits, the two builds run in turn three times). Opening a document keeps the layouts it makes for its type objects, up to 128 MiB, and the first frame uses them instead of laying those objects out again. It does not keep a layout with bitmap or color glyphs or a text object with a gradient. The frame uses a layout only for a text object equal to the one laid out, and only while the fonts and the hyphenation exceptions have not changed. It uses each layout once. The renderer drops the rest when a frame that used one ends, and all of them after eight frames that use none. The frames of a threaded story are laid out while the file is read, and the first frame lays them out again. The pixels are the same. On Windows at 1600 × 1200, in medians of seven interleaved rounds of a local example, counted in the main thread's CPU cycles because other builds were running: with 5,000 point type objects in view the first frame takes 409 million cycles instead of 894 million and the open 816 million instead of 665 million, so the two together take 1,225 million instead of 1,559 million; with one area type object of 500 paragraphs the first frame takes 697 million instead of 1,193 million and the open 535 million instead of 568 million. Type › Show Hidden Characters keeps the marks it places on each text object from one frame to the next. The canvas lays a text object out again when an edit changes it or its threaded story, or when the last frame that showed the marks did not draw it, and it lays out all type in view again when the fonts or the hyphenation exceptions change. With 1,000 two-line labels in view, drawing the marks takes 0.8 ms per frame in a release build instead of 19.7 ms. A selection with more than 100,000 points to outline shows its bounds instead of each object's outline and anchors. Paths count their anchors. Area type and type on a path count the anchors of their frame or path, and point type counts six: its box's corners and the ends of its baseline marker. Images, symbol instances, placed documents, and area type or type on a path in perspective count the four corners of their box, and gradient meshes and envelopes count their points and the ends of their mesh lines. The hover highlight leaves out an object past that count, and such an object shows its bounds as the key object. With 32,768 type objects, images, symbol instances, gradient meshes or envelopes selected, the selection's outlines and anchors add fewer than 100 vertices to the canvas's mesh (#2460). @@ -1372,6 +1406,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur With the Perspective Selection tool, holding Shift while moving or copying constrains each object's displacement to the nearest axis or 45° diagonal of its own plane; releasing Shift resumes the raw pointer position, and 5 still moves perpendicular to the plane. Snap to Grid follows the constrained line on the first object's plane and depth. When the desktop app opens a document on a thread of its own (#1245), that thread also reads the document's linked files and lays out its type, and the UI thread only adds the finished document: in release builds, the step left on the UI thread takes 0.02 ms instead of 1.04 s for a document with 12 linked 3000 × 2000 TIFFs, and it takes 0.1 ms instead of 0.85 s for one with 300 Auto Size area type objects, every second one in three columns. Laying out type keeps each font face's shaping plans, at most 32 per face, so type objects of the same font, script, direction, language and features share one plan: the layouts of a document of 5,000 short point type objects take 59 ms instead of 73 ms in a release build, and the glyphs, bounds and baselines do not change. + Laying out type looks up the font face of each style run once and uses it for the run's glyphs and for the line metrics of a paragraph that starts in that run: the layouts of a document of 5,000 short point type objects take a median of 215 million CPU cycles instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running), and the glyphs, bounds and baselines do not change. The Layers panel's Delete Selection, Duplicate Selection, Collect in New Layer, Merge Selected, Flatten Artwork, Release to Layers (Sequence) and row drags also take time in proportion to the size of the document, and so do Cut Artboard and Duplicate Artboard with their art and each move of an Alt-drag of one artboard with the Artboard tool while Move/Copy Artwork with Artboard is on (#1429). In Outline view the renderer draws paths and compound paths from the geometry it keeps for them between frames, and it culls the members of clip groups by the bounds it keeps for them. Layers with Preview off and the outlines of Image Trace objects are drawn the same way. - **Graphics processor:** the canvas is rasterized on the CPU and the GPU only composites it, so any GPU that can show the window will do. Preferences › Performance › Graphics Processor (`gpuPreference`) is Automatic by default: the power-saving GPU on Windows and macOS (the integrated one on hybrid-graphics laptops, where presenting from the discrete GPU made the window flicker, #306), and the GPU the desktop runs on under Linux, where KWin on an NVIDIA GPU refused frames from a Ryzen's integrated GPU and 0.5.0 crashed at startup (#502). Power Saving and High Performance pick a kind of GPU; `WGPU_POWER_PREF`, `WGPU_ADAPTER_NAME` and `WGPU_BACKEND` override them. The app picks among the adapters that can present to the window (native backends before OpenGL, hardware before software, and on Windows a GPU's DX12 adapter before its Vulkan one: Intel's Vulkan driver made the whole window flicker black, #545), and if the window fails on one while starting up it starts again without it instead of crashing; after a start that a graphics driver crashed before its first frame, the next starts leave that adapter out, or list OpenGL's adapters alone when the crash came while the adapters were being listed (#2243; verified by unit tests, not on the reporter's Intel UHD Graphics 605); Help › About, `ui.inspect` and the log name the adapter in use. When the browser drops the page's WebGPU device or WebGL context, the web build writes recovery copies, starts again on new graphics with the same app and uploads its textures again; if it can't, it removes the stale canvas and says to reload, which offers the copies back. The desktop app writes the copies and says to restart (#369). On Windows and Linux the tools' cursors are OS cursors (bitmaps rasterized once per display scale), which the system moves itself: painted into the window, they trailed the pointer by the frames waiting to be shown, and the window now queues one frame instead of two (#444). macOS and the web still paint them (macOS sizes cursor bitmaps in points, eframe has no bitmap cursors on the web). Tool states have cursors of their own as the public manual describes them: Direct Selection shows a hollow or filled square on its white arrow over an anchor (path unselected or selected) and the Reshape Segment cursor over a curved segment, as do the Anchor Point tool and the Pen with Alt (whose caret it lends); Group Selection, and Direct Selection with Alt, show a plus; a drag shows the filled arrow, hollow while it snaps to a point or a guide, and an Alt-drag copy a double arrowhead; Curvature has its own pen and Free Transform's Cmd/Ctrl side-shear two arrows passing along the side. Lasso, Magic Wand, the freehand tools, Shaper, Live Paint Bucket, Measure, Print Tiling, the symbolism tools, vertical type, Scissors, Mesh and Puppet Warp return cursors of their own, which borrow the crosshair, the I-beam, the Pen's add cursor or not-allowed until they're drawn; there's no busy cursor yet. `crates/tools/src/cursors.toml` lists every cursor, when it shows and its hotspot, for whoever draws them. Pens and touch drive the tools as the mouse does, and a tap pressed and lifted within one frame clicks where it tapped (#491); pen pressure reaches the Paintbrush's pressure-sensitive brushes and the Liquify tools' Use Pressure Pen on Windows, on the web and on Linux under X11 and XWayland, while winit 0.30 reports none on macOS or native Wayland. On Linux, see `docs/development.md` for pens under Wayland. @@ -1406,6 +1441,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Type drawn as glyph outlines:** under a geometry effect, in EMF and WMF export, in DXF export when it is written as outlines, flattened with Convert All Text to Outlines, and inside an envelope, type keeps its underline and strikethrough, the weight and dashes of its character strokes when it is scaled, and the placement of its gradients and patterns, as the canvas draws it. A group's or layer's own fills and strokes also paint the underline and strikethrough of the type in it. +- **Envelopes of type map from the glyphs:** an envelope maps point type, area type, type in groups and layers and type used as a clipping path from the bounds of the glyph outlines, underlines and strikethroughs instead of the type's laid-out box (ascent to descent, or the area's frame), with inline graphics counted by their boxes; rotated type and type in perspective map from their outlines as drawn. Make with Mesh, Make with Top Object and Reset with Mesh without Maintain Shape use those bounds. Type that draws nothing keeps its laid-out box, and so does type inside any other object, such as a blend, a repeat or a symbol instance. This is VectorCraft's own rule; envelopes of type made in VectorCraft and in saved files redraw by it. Tests: `crates/engine/src/tests/envelope.rs`, and `tests/envelope_ink.rs` in `vectorcraft-eps`, `vectorcraft-render` and `vectorcraft-effects`. Scores and hours unchanged. + - **Texture redraws (#1257):** Craquelure, Mosaic Tiles and Stained Glass compare squared distances when finding their nearest cells; rounding/tie boundaries use the original distance ordering. Brute-force candidate tests and three 1800×1500 viewport comparisons preserve the old pixels. A CMYK rectangle move at 190.07%/DPI 1.5 is reproducible with `cargo test -p vectorcraft-render texture_drag_perf -- --ignored --nocapture`; idle-machine Windows interaction is still unverified. - **Smart Blur modes:** Effect › Blur › Smart Blur… has a Mode menu with Normal, Edge Only and Overlay Edge (`blurMode`: `normal`, `edgeOnly` or `overlayEdge`; an effect saved without it draws as Normal). Edge Only draws the edges in white on black, and Overlay Edge draws them in white over the Normal blur. A pixel is on an edge when one of the pixels the search reads within Radius of it differs from it by more than Threshold in a channel and its channels, alpha included, add up to at least that pixel's. The search reads neighbors as the Normal blur does, kept within Radius, and always the four nearest pixels, so a color step gives a line about as wide as Radius, and at least one pixel wide, on the side with the larger sum (lines on both sides when the sums are equal). The white and the black keep each pixel's coverage. Behavior reference: [Summary of effects](https://helpx.adobe.com/illustrator/using/summary-effects.html). @@ -1438,15 +1475,20 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **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. +- **Envelope names given as another type:** `object.envelope.makeWithWarp`, `resetWithWarp` and `options` refuse a `style` or `orientation` other than text or null, such as `5`, and an `orientation` other than `horizontal` or `vertical`; `object.envelope.options` refuses such a `preserveShape`. `object.envelope.options` checks the warp keys with no envelope or only mesh envelopes selected, too. Each leaves the document, the options new envelopes get and the undo history as they were. + - **Exporter plug-ins (G17):** a WebAssembly plug-in of kind `exporter` adds a write-only file format. File › Export › Export As lists it after the built-in formats, with Use Artboards greyed out (the plug-in writes the whole document) and its parameters in a generated dialog once the file is picked; `document.export` writes it by its format id, `plugin.`, or by one of its extensions, and built-in formats keep theirs. The plug-in is given the native file's JSON (large images without their data) and `_context` (`mode`, `extension`, `name`), with 10 G instructions plus 4,000 per byte of input and 300 s, and Background Export's Cancel stops it between two slices of that budget. A decline or invalid output writes nothing; its warnings come back with the export's. Regressions: `crates/engine/tests/plugin_export.rs`, `crates/plugins/tests/abi.rs`, `crates/ui-egui/src/dialogs/tests_plugin_export.rs`. Scores and hours unchanged. - **Importer plug-ins (G17):** a WebAssembly plug-in of kind `importer` adds a read-only file format, as [the help page](https://ai-sdk-2020.84.nz/html/struct_a_i_file_format_suite) describes for file format plug-ins that read: File › Open lists its files under "All readable files" and its own filter, and File › Open With lists each importer by name. `document.open` uses it for a file whose extension no built-in format reads, by content or extension (the importer whose id sorts first, when several list the extension), or the one `importer` names, also for an extension a built-in format reads; a damaged file of a built-in format reports that format's error. The plug-in is given at most 128 MiB of the file and `_context` (`mode: "import"`, `extension`, `name`), and returns paths and compound paths in the filters' object JSON, checked as a filter's output is; they become a new document whose artboard is their bounds, which Save asks a name for. A decline, invalid output, a trap or Cancel opens nothing. The routing rule, the limit and Open With are VectorCraft's own. Regressions: `crates/engine/tests/plugin_import.rs`, `crates/plugins/src/import.rs`, `crates/ui-egui/src/dialogs/tests_plugin_import.rs`. Scores and hours unchanged. +- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in parameters as their manifest presents them (G17):** each parameter may have a `label`, a `unit` shown after its value, `help` on its label and a `helpUrl` page opened from a button, a `group` (sections that fold, after the parameters outside any group) and a `visibleWhen` that shows it only while the checkboxes and choices it names have one of the listed values (a hidden parameter still goes to the plug-in). `string` and `numberList` parameters join number, int, bool and choice, and a plug-in declares up to 256 parameters, at most 64 outside groups. The filter, live-effect and exporter dialogs use these keys, and the Appearance panel's effect editor the labels, units, help and conditions; hosts from before them ignore them. Regressions: `crates/plugins/src/dialog.rs`, `crates/plugins/src/manifest.rs`, `crates/ui-egui/src/dialogs/tests_plugin_fields.rs`, `crates/ui-egui/src/panels/appearance.rs`. Scores and hours unchanged. - **Live-effect plug-ins read their object's paints (G17):** a live effect is given the object's `fills` and `strokes` as a filter gets them, read only, and `_context.item` (`"object"`, or the fill or stroke whose own effects hold it); the result is cached by paint too, so a change of paint runs it again. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/effect.rs`. Scores and hours unchanged. - **Filter plug-ins set effects and stroke styles (G17):** a filter is given each object's effect records and its strokes' `join`, `cap` and `miterLimit`, and may return them; the records are admitted as `effect.apply` admits them (a known effect or installed live-effect plug-in, parameters it takes, one 3D effect that fits, no texture read from a file), and one refused refuses the output. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged. - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. +- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. diff --git a/docs/three-d.md b/docs/three-d.md index 7cc339dff..bb0995315 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -14,7 +14,36 @@ Select a path or compound path, then choose **Effect › 3D and Materials › Ex artboard. Preview changes are temporary until OK; Cancel restores the document; OK is one undo step. An object takes one -3D effect: edit its options in the Appearance panel, or remove it before applying another. +3D effect: edit its options in the Appearance panel or the 3D and Materials panel, or remove it before applying +another. + +## 3D and Materials panel + +**Window › 3D and Materials** opens a panel that edits the 3D effect of the selection without a dialog, as the +public [3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html) +and [Create 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-vector-artwork.html) +pages describe it. + +- The object types **Plane**, **Extrude**, **Revolve** and **Inflate** sit at the top. A type applies its effect + to each selected object that has no 3D effect (Plane is Rotate, Extrude is Extrude & Bevel), and changes the + 3D effect of each other selected object in place (`effect.setParams` with `threeDType`): the settings both + effects take keep their values, and the rest start at the new effect's defaults. One click is one undo step, + and an object whose effect already has the type does not change. +- Below them are the Object, Rotation and Lighting tabs of the effect's options dialog, with the same controls + and ranges, showing the 3D effect of the first selected object (its own effects first, then those of its fills + and strokes). A changed setting goes to the 3D effect of each selected object whose effect takes that setting; + other effects and objects without a 3D effect do not change. A setting the selected 3D effects hold with + different values shows its default with its reset dot filled; a checkbox shows a dash instead, and a mixed + Shading leaves the light settings available. Its reset dot, a choice from its menu or a click on its checkbox + (which turns a mixed one on) sets it on all of them; a typed or dragged number sets it when it differs from the + default shown. Other changes leave it as each effect has it. + The list of lights shows the first object's lights, and an edit to it (Add Light, Delete Light, or a setting + of one light in a list) reaches only the objects whose list of lights matches the first object's. Add Light is + unavailable while a setting of a single light differs between the selected effects. +- Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step + when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing. +- The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on + the artboard are part of the options dialog only. ## Extrude & Bevel diff --git a/docs/type-parity.md b/docs/type-parity.md index b36cb4892..010c74aa5 100644 --- a/docs/type-parity.md +++ b/docs/type-parity.md @@ -21,7 +21,7 @@ against Illustrator's public documentation; **estimated** unless noted. | Mouse wheel over the closed font-family field | done | #5253: Character, Properties and Control step selected text through the family list using saved font filters (excluding search text), one undo step per notch; the style is matched and the list stops at either end. Escape and leaving the field keep the change. Order, bounds and Undo grouping are VectorCraft's choices; scores unchanged. | | Soft return (forced line break, Shift+Enter) | done | Shift+Enter inserts a soft return (U+2028), breaking the line without starting a new paragraph; paragraph spacing (space before/after) and first-line indents apply only at paragraph ends; continuation lines in lists do not repeat the marker; soft return lines in justified text do not spread (#2754) | | Native font-family labels | partial | With Show Font Names in English off, localized legacy names are used when typographic names contain no native label (#2208); loaded-font and catalog-only regressions. Choosing among multiple native languages is unchanged. | -| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802); `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | +| Point, area and type-on-a-path type; vertical variants | done / partial | vertical point, area and path type have initial support; fullwidth `!` / `?` keep the nominal glyph upright when no vertical alternate exists (Unicode Tu); in a non-rectangular area a word too wide for the top lines moves down to the first line that holds it (#1615); Escape while typing ends the edit, keeps the type selected and switches to the Selection tool (#1802), and Cmd+Enter (Ctrl+Enter on Windows and Linux) does the same unless a command is bound to it; `text.create` and `text.createInPath` take `justify` (#1752); MCP `add_text` forwards `vertical` to both commands; the Type and Vertical Type tools make area type when a closed path's outline is clicked and type on a path when an open one is, with pointers that say which, as the public help describes ([Create text](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html), [Create type on a path](https://web.archive.org/web/20220127090635/https://helpx.adobe.com/illustrator/using/creating-type-path.html)) | | Area Type Options (rows, columns, inset, first baseline, Text Flow by rows or by columns, Auto Size, Shrink Text to Fit) | done | Text Flow fills the cells down each column first (By Columns, the default, as files from before it open) or across each row first (By Rows), in the dialog and `text.areaOptions {flow}` (#4978); the commands that make or restyle type say when it does not fit its frame; MCP `add_text` takes Auto Size and Shrink Text to Fit (#1471) | | Threaded text, Fit Headline, Text Wrap | done | Fit Headline sees wrapping in a frame one line tall (#1444); a thread holds area type and type on a path in any mix, as Illustrator links type in an area or along a path ([help](https://helpx.adobe.com/illustrator/using/manage-text-area.html)) | | Threading from the ports: selected area type and type on a path show an in port and an out port, empty, with an arrow on a side where the frame is threaded, or with a red "+" in the out port when the text doesn't fit; a click on either port loads the text; a click on a closed path, area type or type on a path threads into it, a click on empty canvas makes a frame of the same kind, shape and size there, a drag draws a rectangular frame; the frame placed comes after the clicked one from its out port and before it from its in port (the story then starts there); Escape or another tool cancels; a double-click on a threaded port breaks the thread there | partial | #2572: the Selection tool, `text.thread.add {from, to?, rect?, at?, before?}`, as the reference app's help describes ports and threading ([archived help, Threading text between objects](https://web.archive.org/web/20210119051048/https://helpx.adobe.com/illustrator/using/creating-text.html), [create text threads](https://helpx.adobe.com/illustrator/desktop/design-with-text/add-manage-text/create-text-threads-between-objects.html)); area type's in port sits on the left side below the top-left corner, type on a path's just before its start bracket; Show Text Threads draws its lines between the same ports; with more than 1,000 such objects selected no ports are drawn. a double-click on a port where the frame is threaded breaks the thread there, the story flowing into the frames before the break and the frames after it left empty, still threaded with each other when two or more (`text.thread.break`, [archived help](https://web.archive.org/web/20220127090642/https://helpx.adobe.com/illustrator/using/creating-text.html)). Remaining: the ports only take clicks with the Selection tool, and the loaded cursor borrows the I-beam until it's drawn | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 3117a7af1..6b8bda8d1 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -63,6 +63,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("plugins", Class::Layer(2)), ("affinity", Class::Standalone), ("scriptcheck", Class::Standalone), + ("js", Class::Standalone), ]; /// Explicit orderings *within* a layer (earlier may be used by later). diff --git a/xtask/src/main.rs b/xtask/src/main.rs index de6ff5815..334910732 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -14,6 +14,7 @@ mod pdf_deps; mod pngsuite; mod sha256; mod stats; +mod test262; mod version; mod web_fonts; @@ -41,6 +42,8 @@ commands: ico ... pack PNGs into a Windows .ico (used by packaging/icons.sh) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + test262 fetch the pinned ECMAScript conformance suite into corpus/test262 (sha256 + verified) and parse its ES5 subset with vectorcraft-js, printing the counts web-fonts copy the craft-fonts faces in crates/text/web-fonts.txt into the web site (the Trunk post_build hook; a no-op without CRAFT_FONTS_DIR) "; @@ -64,6 +67,7 @@ fn main() -> ExitCode { Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("test262") => test262::run_suite(), Some("web-fonts") => web_fonts::run(&root()), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); @@ -333,6 +337,8 @@ Tests that use a corpus skip cleanly when it is absent. corpus/raw/ raw.pixls.us samples, CC0 (optional) corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256 verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity + corpus/test262/ the ECMAScript conformance suite (BSD-3-Clause) at a pinned commit, sha256 + verified: the JavaScript parser's ES5 subset. Fetch and run: cargo xtask test262 ", corpus.display() ); diff --git a/xtask/src/test262.rs b/xtask/src/test262.rs new file mode 100644 index 000000000..ab390538b --- /dev/null +++ b/xtask/src/test262.rs @@ -0,0 +1,73 @@ +//! `cargo xtask test262`: the official ECMAScript conformance suite (https://github.com/tc39/test262, +//! BSD-3-Clause) at a pinned commit, fetched as GitHub's archive of that commit, checked against +//! its SHA-256 and size, and unpacked into `corpus/test262/` (gitignored, never committed). It then +//! runs `vectorcraft-js`'s `test262` test, which parses the ES5 parse subset and prints its counts. +//! +//! Moving the pin: change [`COMMIT`], run the command once with the new commit to see the +//! archive's SHA-256 in the error, check the archive, and update [`SHA256`]. + +use std::path::Path; +use std::process::Command; + +use crate::{cargo, root, run, sha256}; + +/// The tc39/test262 commit the suite is read at. +const COMMIT: &str = "d53ebc8b1931d9f1541e43d59650776e4b766248"; + +/// The SHA-256 of GitHub's `tar.gz` archive of [`COMMIT`]. +const SHA256: &str = "6db8566044d1a3f5a68aac87806e09b2fe5d21f5869f3a74cfd8b7236ebaf5c8"; + +/// The largest archive accepted (the pinned one is 9.6 MB). +const MAX_BYTES: u64 = 64 << 20; + +pub fn run_suite() -> Result<(), String> { + let dir = fetch()?; + let mut c = cargo(); + c.env("VECTORCRAFT_TEST262_DIR", &dir).args(["test", "-p", "vectorcraft-js", "--release", "--test", "test262", "--", "--nocapture"]); + run(c, &format!("cargo test -p vectorcraft-js --release --test test262 -- --nocapture (VECTORCRAFT_TEST262_DIR={})", dir.display())) +} + +/// Fetches and unpacks the suite unless it is there already, and returns its folder. +fn fetch() -> Result { + let base = root().join("corpus").join("test262"); + let dir = base.join(format!("test262-{COMMIT}")); + let marker = dir.join(".vectorcraft-sha256"); + if std::fs::read_to_string(&marker).is_ok_and(|s| s.trim() == SHA256) { + println!("test262 {COMMIT} is in {}", dir.display()); + return Ok(dir); + } + std::fs::create_dir_all(&base).map_err(|e| format!("create {}: {e}", base.display()))?; + let archive = base.join(format!("test262-{COMMIT}.tar.gz")); + if !verified(&archive)? { + let url = format!("https://codeload.github.com/tc39/test262/tar.gz/{COMMIT}"); + let mut curl = Command::new("curl"); + curl.args(["-fsSL", "--retry", "3", "-A", "VectorCraft-dev", "--max-filesize", &MAX_BYTES.to_string(), "-o"]).arg(&archive).arg(&url); + run(curl, &format!("curl {url}"))?; + if !verified(&archive)? { + let got = std::fs::read(&archive).map(|b| sha256::hex(&b)).unwrap_or_default(); + let _ = std::fs::remove_file(&archive); + return Err(format!("test262 archive: sha256 {got} does not match the pinned {SHA256}")); + } + } + if dir.exists() { + std::fs::remove_dir_all(&dir).map_err(|e| format!("remove {}: {e}", dir.display()))?; + } + let mut tar = Command::new("tar"); + tar.arg("-xzf").arg(&archive).arg("-C").arg(&base); + run(tar, &format!("tar -xzf {}", archive.display()))?; + if !dir.join("test").join("language").is_dir() { + return Err(format!("{} has no test/language folder after unpacking", dir.display())); + } + std::fs::write(&marker, SHA256).map_err(|e| format!("{}: {e}", marker.display()))?; + println!("test262 {COMMIT} unpacked into {}", dir.display()); + Ok(dir) +} + +/// The archive is there, within the size limit, with the pinned SHA-256. +fn verified(path: &Path) -> Result { + let Ok(meta) = std::fs::metadata(path) else { return Ok(false) }; + if meta.len() > MAX_BYTES { + return Err(format!("{} is {} bytes, over the {MAX_BYTES}-byte limit", path.display(), meta.len())); + } + Ok(std::fs::read(path).is_ok_and(|b| sha256::hex(&b) == SHA256)) +}